Compare commits
18 Commits
1fb859fdf9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afc714022f | ||
|
|
c85450ce37 | ||
|
|
c5bc8f5b49 | ||
|
|
14c80baeec | ||
|
|
5287611bf7 | ||
|
|
cb2d1f34d3 | ||
|
|
97c8e1c9fb | ||
|
|
5485da4029 | ||
|
|
b579b32138 | ||
|
|
4337a8f69a | ||
|
|
a007ac11ce | ||
|
|
34e831f738 | ||
|
|
cfbb3d50ee | ||
|
|
1c2222da67 | ||
|
|
bb71b892ca | ||
|
|
ab81a3da3d | ||
|
|
5f72e6ce7c | ||
|
|
1ec2df27b0 |
37
.cursor/.prompt/new.md
Normal file
@@ -0,0 +1,37 @@
|
||||
7.게시판 열 구분선 잘보이게 요청 / 모바일 최적화 요청 및 구분선,높이 조절 요청
|
||||
8.글삭제기능 어드민추가
|
||||
|
||||
9-1.시크로드 참고 - 제휴업체 리스트, 지역별 / 제휴업체 정보 생성 요청 (제휴업체 프로필,출근부)
|
||||
9-2.제휴업체 및 프로필 등록은, 권한을부여한 제휴업체가 직접 등록하도록 해야합니다
|
||||
9-3.제휴업체 등록 주인이하면안됨, 시크로드 - 제휴문의 - 제휴문의글쓰기 참고요청
|
||||
제휴문의 게시판 ( 관리자 승인후) → 이동
|
||||
제휴업소 리스트 게시판 (노출됨)"
|
||||
|
||||
10.배너관리 이미지 사이즈 규격 및 제휴업체 등록시 이미지 사이즈 규격
|
||||
11,게시판, 주간인기글 , 일간인기글 추가 요청
|
||||
|
||||
12.https://search.google.com/search-console/welcome 등록요청
|
||||
13.메인페이지 제휴업체 배너 이미지 규격작게 수정요청, 한번에 4개이상 정도 보이는 정도
|
||||
14.메인페이지 제휴업체 클릭시 제휴업체 카테고리 하이퍼연결 추가 요청
|
||||
15.레벨별 아이콘 페이지 / https://seekrod.co.kr/bbs/page.php?hid=point 참고
|
||||
16. 게시글 상단고정 기능
|
||||
17. 메인화면 큰 카테고리 빼기
|
||||
18. google SEO: header, meta, description 동적설정
|
||||
2. 회색 배경 영역(비밀글, 글자수)이 글 작성 영역 안에 있어야 함
|
||||
로그인 프로세스 수정: 로그인시 로그인 팝업 모달
|
||||
로그인 로그오프 시, 메인 프로필 디자인 누락되서 추가(지금상태로 가도 무상관)
|
||||
1. 게시글에서 목록돌아가는 버튼 디자인과 다름
|
||||
2. 게시글에서 제목, 내용, 댓글 디자인 다름
|
||||
3. 게시글에서 최하단에 게시글리스트 부분 디자인 다
|
||||
4. 게시글 리스트에서 리스트버튼 하단 divider 색상이 너무 연함
|
||||
5. 표시개수 필요없을 거 같은데, 그냥 고정해버리죠?
|
||||
|
||||
9. 게시글 이미지 배치사이즈
|
||||
10. 외부접속 가입없어도 가능
|
||||
11. 글뷰에서 게시글 리스트로
|
||||
12. 게시글 리스트 디자인
|
||||
13. 포인트 규칙
|
||||
14. 게시판권한 확인
|
||||
15.메인뷰 3열 사이즈 변경
|
||||
16. 로그인 안됐을때 카드 휑함
|
||||
17.이미지 사이즈 미리불러오기
|
||||
3
middleware.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { middleware, config } from "./src/middleware";
|
||||
|
||||
|
||||
@@ -217,6 +217,7 @@ model Post {
|
||||
reports Report[]
|
||||
stat PostStat?
|
||||
viewLogs PostViewLog[]
|
||||
dailyViews DailyPostView[]
|
||||
|
||||
@@index([boardId, status, createdAt])
|
||||
@@index([boardId, isPinned, pinnedOrder])
|
||||
@@ -225,12 +226,12 @@ model Post {
|
||||
|
||||
// 사용자
|
||||
model User {
|
||||
userId String @id @default(cuid())
|
||||
userId String @id
|
||||
nickname String @unique
|
||||
passwordHash String?
|
||||
name String
|
||||
birth DateTime
|
||||
phone String @unique
|
||||
birth DateTime?
|
||||
phone String? @unique
|
||||
rank Int @default(0)
|
||||
// 누적 포인트, 레벨, 등급(0~10)
|
||||
points Int @default(0)
|
||||
@@ -259,6 +260,7 @@ model User {
|
||||
blocksInitiated Block[] @relation("Blocker")
|
||||
blocksReceived Block[] @relation("Blocked")
|
||||
pointTxns PointTransaction[]
|
||||
attendances Attendance[]
|
||||
sanctions Sanction[]
|
||||
nicknameChanges NicknameChange[]
|
||||
passwordResetTokens PasswordResetToken[] @relation("PasswordResetUser")
|
||||
@@ -437,6 +439,21 @@ model PostStat {
|
||||
@@map("post_stats")
|
||||
}
|
||||
|
||||
// 일일 게시글 조회수 (날짜별 집계)
|
||||
model DailyPostView {
|
||||
id String @id @default(cuid())
|
||||
postId String
|
||||
date DateTime // 날짜만 사용 (시간은 00:00:00)
|
||||
viewCount Int @default(0)
|
||||
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([postId, date])
|
||||
@@index([date, viewCount])
|
||||
@@index([postId, date])
|
||||
@@map("daily_post_views")
|
||||
}
|
||||
|
||||
// 신고(게시글/댓글 대상)
|
||||
model Report {
|
||||
id String @id @default(cuid())
|
||||
@@ -506,6 +523,20 @@ model PointTransaction {
|
||||
@@map("point_transactions")
|
||||
}
|
||||
|
||||
// 출석부 기록 (사용자별 일자 단위 출석)
|
||||
model Attendance {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
date DateTime // 자정 기준 날짜만 사용
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [userId], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, date])
|
||||
@@index([userId, date])
|
||||
@@map("attendance")
|
||||
}
|
||||
|
||||
// 레벨 임계치(선택)
|
||||
model LevelThreshold {
|
||||
id String @id @default(cuid())
|
||||
@@ -674,6 +705,7 @@ model Partner {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
category String
|
||||
categoryId String?
|
||||
latitude Float
|
||||
longitude Float
|
||||
address String?
|
||||
@@ -682,11 +714,27 @@ model Partner {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
categoryRef PartnerCategory? @relation(fields: [categoryId], references: [id])
|
||||
|
||||
@@index([category])
|
||||
@@index([categoryId])
|
||||
@@index([sortOrder])
|
||||
@@map("partners")
|
||||
}
|
||||
|
||||
// 제휴업체 카테고리(관리자 생성/삭제)
|
||||
model PartnerCategory {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
partners Partner[]
|
||||
|
||||
@@index([sortOrder])
|
||||
@@map("partner_categories")
|
||||
}
|
||||
|
||||
// 배너/공지 노출용
|
||||
model Banner {
|
||||
id String @id @default(cuid())
|
||||
@@ -729,6 +777,10 @@ model PartnerRequest {
|
||||
longitude Float
|
||||
address String?
|
||||
contact String?
|
||||
region String?
|
||||
imageUrl String?
|
||||
sortOrder Int @default(0)
|
||||
active Boolean @default(true)
|
||||
status String @default("pending") // pending/approved/rejected
|
||||
createdAt DateTime @default(now())
|
||||
approvedAt DateTime?
|
||||
@@ -749,18 +801,4 @@ model Setting {
|
||||
}
|
||||
|
||||
// 메인 노출용 제휴 샵 가로 스크롤 데이터
|
||||
model PartnerShop {
|
||||
id String @id @default(cuid())
|
||||
region String
|
||||
name String
|
||||
address String
|
||||
imageUrl String
|
||||
sortOrder Int @default(0)
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([name, region])
|
||||
@@index([active, sortOrder])
|
||||
@@map("partner_shops")
|
||||
}
|
||||
// PartnerShop 모델 제거됨 (PartnerRequest로 통합)
|
||||
|
||||
218
prisma/seed.js
@@ -119,22 +119,36 @@ async function createRandomUsers(count = 100) {
|
||||
}
|
||||
}
|
||||
|
||||
const createdUsers = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
// 닉네임을 결정론적으로 생성해 재실행 시 중복 생성 방지
|
||||
const nickname = `user${String(i + 1).padStart(3, "0")}`;
|
||||
const existing = await prisma.user.findUnique({ where: { nickname } });
|
||||
// 고정 ID: user001, user002, ...
|
||||
const userId = `user${String(i + 1).padStart(3, "0")}`;
|
||||
const existing = await prisma.user.findUnique({ where: { userId } });
|
||||
let user = existing;
|
||||
if (!existing) {
|
||||
const name = generateRandomKoreanName();
|
||||
const birth = randomDate(1975, 2005);
|
||||
const phone = await findAvailablePhone(i + 2); // admin이 0001 사용하므로 겹치지 않도록 오프셋
|
||||
// 닉네임: 중복 없는 랜덤 한글 생성
|
||||
let nickname = generateRandomKoreanName();
|
||||
for (let tries = 0; tries < 10; tries++) {
|
||||
const dup = await prisma.user.findUnique({ where: { nickname } });
|
||||
if (!dup) break;
|
||||
nickname = generateRandomKoreanName();
|
||||
}
|
||||
// 그래도 중복이면 희귀 조합 한 번 더 시도
|
||||
const finalDup = await prisma.user.findUnique({ where: { nickname } });
|
||||
if (finalDup) {
|
||||
nickname = generateRandomKoreanName();
|
||||
}
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
userId,
|
||||
nickname,
|
||||
name,
|
||||
birth,
|
||||
phone,
|
||||
passwordHash: hashPassword("1234"),
|
||||
passwordHash: hashPassword("12341234"),
|
||||
agreementTermsAt: new Date(),
|
||||
authLevel: "USER",
|
||||
isAdultVerified: Math.random() < 0.6,
|
||||
@@ -145,7 +159,7 @@ async function createRandomUsers(count = 100) {
|
||||
// 기존 사용자도 패스워드를 1234로 업데이트
|
||||
await prisma.user.update({
|
||||
where: { userId: user.userId },
|
||||
data: { passwordHash: hashPassword("1234") },
|
||||
data: { passwordHash: hashPassword("12341234") },
|
||||
});
|
||||
}
|
||||
if (roleUser && user) {
|
||||
@@ -155,7 +169,9 @@ async function createRandomUsers(count = 100) {
|
||||
create: { userId: user.userId, roleId: roleUser.roleId },
|
||||
});
|
||||
}
|
||||
if (user) createdUsers.push(user);
|
||||
}
|
||||
return createdUsers;
|
||||
}
|
||||
|
||||
async function upsertCategories() {
|
||||
@@ -164,7 +180,6 @@ async function upsertCategories() {
|
||||
{ name: "메인", slug: "main", sortOrder: 1, status: "active" },
|
||||
{ name: "명예의 전당", slug: "hall-of-fame", sortOrder: 2, status: "active" },
|
||||
{ name: "소통방", slug: "community", sortOrder: 3, status: "active" },
|
||||
{ name: "TEST", slug: "test", sortOrder: 4, status: "active" },
|
||||
];
|
||||
const map = {};
|
||||
for (const c of categories) {
|
||||
@@ -238,17 +253,18 @@ async function upsertAdmin() {
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { nickname: "admin" },
|
||||
update: {
|
||||
passwordHash: hashPassword("1234"),
|
||||
passwordHash: hashPassword("12341234"),
|
||||
grade: 7,
|
||||
points: 1650000,
|
||||
level: 200,
|
||||
},
|
||||
create: {
|
||||
userId: "admin",
|
||||
nickname: "admin",
|
||||
name: "Administrator",
|
||||
birth: new Date("1990-01-01"),
|
||||
phone: "010-0000-0001",
|
||||
passwordHash: hashPassword("1234"),
|
||||
passwordHash: hashPassword("12341234"),
|
||||
agreementTermsAt: new Date(),
|
||||
authLevel: "ADMIN",
|
||||
grade: 7,
|
||||
@@ -270,6 +286,55 @@ async function upsertAdmin() {
|
||||
return admin;
|
||||
}
|
||||
|
||||
async function seedAdminAttendance(admin) {
|
||||
try {
|
||||
const now = new Date();
|
||||
const year = now.getUTCFullYear();
|
||||
const days = [1, 2, 5, 6]; // 11월 1,2,5,6일
|
||||
for (const d of days) {
|
||||
const date = new Date(Date.UTC(year, 10, d, 0, 0, 0, 0)); // 10 = November (0-based)
|
||||
// @@unique([userId, date]) 기준으로 업서트
|
||||
await prisma.attendance.upsert({
|
||||
where: { userId_date: { userId: admin.userId, date } },
|
||||
update: {},
|
||||
create: { userId: admin.userId, date },
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("seedAdminAttendance failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedRandomAttendanceForUsers(users, minDays = 10, maxDays = 20) {
|
||||
try {
|
||||
const today = new Date();
|
||||
const todayUtcMidnight = new Date(Date.UTC(
|
||||
today.getUTCFullYear(),
|
||||
today.getUTCMonth(),
|
||||
today.getUTCDate(),
|
||||
0, 0, 0, 0
|
||||
));
|
||||
for (const user of users) {
|
||||
const count = randomInt(minDays, maxDays);
|
||||
const used = new Set();
|
||||
while (used.size < count) {
|
||||
const offsetDays = randomInt(0, 120); // 최근 120일 범위에서 랜덤
|
||||
const date = new Date(todayUtcMidnight.getTime() - offsetDays * 24 * 60 * 60 * 1000);
|
||||
const key = date.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
if (used.has(key)) continue;
|
||||
used.add(key);
|
||||
await prisma.attendance.upsert({
|
||||
where: { userId_date: { userId: user.userId, date } },
|
||||
update: {},
|
||||
create: { userId: user.userId, date },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("seedRandomAttendanceForUsers failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertBoards(admin, categoryMap) {
|
||||
const boards = [
|
||||
// 일반
|
||||
@@ -286,17 +351,16 @@ async function upsertBoards(admin, categoryMap) {
|
||||
{ name: "회원랭킹", slug: "ranking", description: "랭킹", sortOrder: 14 },
|
||||
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", sortOrder: 15 },
|
||||
{ name: "월간집계", slug: "monthly-stats", description: "월간 통계", sortOrder: 16 },
|
||||
// TEST
|
||||
{ name: "TEST", slug: "test", description: "테스트 게시판", sortOrder: 20 },
|
||||
// 광고/제휴 계열 게시판은 제거(메인/명예의전당/소통방 외)
|
||||
];
|
||||
|
||||
const created = [];
|
||||
// 특수 랭킹/텍스트/미리보기 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨)
|
||||
// 텍스트/특수랭킹/특수출석 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨)
|
||||
const mainText = await prisma.boardViewType.findUnique({ where: { key: "main_text" } });
|
||||
const listText = await prisma.boardViewType.findUnique({ where: { key: "list_text" } });
|
||||
const mainSpecial = await prisma.boardViewType.findUnique({ where: { key: "main_special_rank" } });
|
||||
const listSpecial = await prisma.boardViewType.findUnique({ where: { key: "list_special_rank" } });
|
||||
const mainText = await prisma.boardViewType.findUnique({ where: { key: "main_text" } });
|
||||
const mainPreview = await prisma.boardViewType.findUnique({ where: { key: "main_preview" } });
|
||||
const listSpecialAttendance = await prisma.boardViewType.findUnique({ where: { key: "list_special_attendance" } });
|
||||
|
||||
for (const b of boards) {
|
||||
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
|
||||
@@ -316,8 +380,6 @@ async function upsertBoards(admin, categoryMap) {
|
||||
qna: "community",
|
||||
tips: "community",
|
||||
anonymous: "community",
|
||||
// TEST
|
||||
test: "test",
|
||||
// 광고/제휴
|
||||
};
|
||||
const categorySlug = mapBySlug[b.slug] || "community";
|
||||
@@ -330,13 +392,12 @@ async function upsertBoards(admin, categoryMap) {
|
||||
allowAnonymousPost: !!b.allowAnonymousPost,
|
||||
readLevel: b.readLevel || undefined,
|
||||
categoryId: category ? category.id : undefined,
|
||||
// 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지, TEST는 미리보기
|
||||
...(b.slug === "ranking"
|
||||
? (mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {})
|
||||
: b.slug === "test"
|
||||
? (mainPreview ? { mainPageViewTypeId: mainPreview.id } : {})
|
||||
: (mainText ? { mainPageViewTypeId: mainText.id } : {})),
|
||||
// 기본은 텍스트, 'ranking'은 특수랭킹, 'attendance'는 특수출석으로 오버라이드
|
||||
...(mainText ? { mainPageViewTypeId: mainText.id } : {}),
|
||||
...(listText ? { listViewTypeId: listText.id } : {}),
|
||||
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
|
||||
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
||||
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
|
||||
},
|
||||
create: {
|
||||
name: b.name,
|
||||
@@ -346,13 +407,12 @@ async function upsertBoards(admin, categoryMap) {
|
||||
allowAnonymousPost: !!b.allowAnonymousPost,
|
||||
readLevel: b.readLevel || undefined,
|
||||
categoryId: category ? category.id : undefined,
|
||||
// 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지, TEST는 미리보기
|
||||
...(b.slug === "ranking"
|
||||
? (mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {})
|
||||
: b.slug === "test"
|
||||
? (mainPreview ? { mainPageViewTypeId: mainPreview.id } : {})
|
||||
: (mainText ? { mainPageViewTypeId: mainText.id } : {})),
|
||||
// 기본은 텍스트, 'ranking'은 특수랭킹, 'attendance'는 특수출석으로 오버라이드
|
||||
...(mainText ? { mainPageViewTypeId: mainText.id } : {}),
|
||||
...(listText ? { listViewTypeId: listText.id } : {}),
|
||||
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
|
||||
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
||||
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
|
||||
},
|
||||
});
|
||||
created.push(board);
|
||||
@@ -373,16 +433,15 @@ async function upsertBoards(admin, categoryMap) {
|
||||
|
||||
async function upsertViewTypes() {
|
||||
const viewTypes = [
|
||||
// main scope
|
||||
{ key: "main_default", name: "기본", scope: "main" },
|
||||
// main scope (기본/없음 제거, 텍스트 중심)
|
||||
{ key: "main_text", name: "텍스트", scope: "main" },
|
||||
{ key: "main_preview", name: "미리보기", scope: "main" },
|
||||
{ key: "main_special_rank", name: "특수랭킹", scope: "main" },
|
||||
// list scope
|
||||
{ key: "list_default", name: "기본", scope: "list" },
|
||||
// list scope (기본/없음 제거, 텍스트 중심)
|
||||
{ key: "list_text", name: "텍스트", scope: "list" },
|
||||
{ key: "list_preview", name: "미리보기", scope: "list" },
|
||||
{ key: "list_special_rank", name: "특수랭킹", scope: "list" },
|
||||
{ key: "list_special_attendance", name: "특수출석", scope: "list" },
|
||||
];
|
||||
for (const vt of viewTypes) {
|
||||
await prisma.boardViewType.upsert({
|
||||
@@ -404,6 +463,8 @@ async function createPostsForAllBoards(boards, countPerBoard = 100, admin) {
|
||||
const users = await prisma.user.findMany({ select: { userId: true } });
|
||||
const userIds = users.map((u) => u.userId);
|
||||
for (const board of boards) {
|
||||
// 회원랭킹 보드는 특수랭킹용이라 게시글을 시드하지 않습니다.
|
||||
if (board.slug === "ranking") continue;
|
||||
const data = [];
|
||||
for (let i = 0; i < countPerBoard; i++) {
|
||||
const authorId = ["notice", "bug-report"].includes(board.slug)
|
||||
@@ -463,10 +524,10 @@ async function seedPartnerShops() {
|
||||
{ region: "강원도", name: "test2", address: "춘천시 중앙로 112", imageUrl: "/sample.jpg", sortOrder: 2 },
|
||||
];
|
||||
for (const it of items) {
|
||||
await prisma.partnerShop.upsert({
|
||||
where: { name_region: { name: it.name, region: it.region } },
|
||||
update: { address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true },
|
||||
create: it,
|
||||
await prisma.partnerRequest.upsert({
|
||||
where: { id: `${it.region}-${it.name}` },
|
||||
update: { address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true, status: "approved" },
|
||||
create: { id: `${it.region}-${it.name}`, region: it.region, name: it.name, address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true, status: "approved", category: "misc", latitude: 37.5, longitude: 127.0 },
|
||||
});
|
||||
}
|
||||
// 표시 토글 기본값 보장
|
||||
@@ -498,7 +559,7 @@ async function seedMainpageVisibleBoards(boards) {
|
||||
const SETTINGS_KEY = "mainpage_settings";
|
||||
const setting = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
|
||||
const current = setting ? JSON.parse(setting.value) : {};
|
||||
const wantSlugs = new Set(["notice", "free", "ranking", "test"]);
|
||||
const wantSlugs = new Set(["notice", "free", "ranking"]);
|
||||
const visibleBoardIds = boards.filter((b) => wantSlugs.has(b.slug)).map((b) => b.id);
|
||||
const next = { ...current, visibleBoardIds };
|
||||
await prisma.setting.upsert({
|
||||
@@ -509,13 +570,80 @@ async function seedMainpageVisibleBoards(boards) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("DATABASE_URL:", process.env.DATABASE_URL);
|
||||
try {
|
||||
const tables = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table'`;
|
||||
console.log("SQLite tables:", tables.map((t) => t.name || t.NAME || JSON.stringify(t)));
|
||||
} catch {}
|
||||
|
||||
// SQLite 수동 보정: partner_categories 테이블과 partners.categoryId 컬럼 보장
|
||||
try {
|
||||
const rows = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table' AND name='partner_categories'`;
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
console.log("Creating missing table: partner_categories");
|
||||
await prisma.$executeRawUnsafe(
|
||||
"CREATE TABLE IF NOT EXISTS partner_categories (\n" +
|
||||
"id TEXT PRIMARY KEY,\n" +
|
||||
"name TEXT NOT NULL UNIQUE,\n" +
|
||||
"sortOrder INTEGER NOT NULL DEFAULT 0,\n" +
|
||||
"createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n" +
|
||||
")"
|
||||
);
|
||||
await prisma.$executeRawUnsafe("CREATE INDEX IF NOT EXISTS idx_partner_categories_sortOrder ON partner_categories(sortOrder)");
|
||||
}
|
||||
// Attendance 테이블 보장 (마이그레이션 미실행 환경 대응)
|
||||
const att = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table' AND name='attendance'`;
|
||||
if (!Array.isArray(att) || att.length === 0) {
|
||||
console.log("Creating missing table: attendance");
|
||||
await prisma.$executeRawUnsafe(
|
||||
"CREATE TABLE IF NOT EXISTS attendance (\n" +
|
||||
"id TEXT PRIMARY KEY,\n" +
|
||||
"userId TEXT NOT NULL,\n" +
|
||||
"date DATETIME NOT NULL,\n" +
|
||||
"createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n" +
|
||||
")"
|
||||
);
|
||||
await prisma.$executeRawUnsafe("CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_user_date ON attendance(userId, date)");
|
||||
await prisma.$executeRawUnsafe("CREATE INDEX IF NOT EXISTS idx_attendance_date ON attendance(date)");
|
||||
}
|
||||
const cols = await prisma.$queryRaw`PRAGMA table_info('partners')`;
|
||||
const hasCategoryId = Array.isArray(cols) && cols.some((c) => (c.name || c.COLUMN_NAME) === 'categoryId');
|
||||
if (!hasCategoryId) {
|
||||
console.log("Adding missing column: partners.categoryId");
|
||||
await prisma.$executeRawUnsafe("ALTER TABLE partners ADD COLUMN categoryId TEXT");
|
||||
// 외래키 제약은 생략 (SQLite에서는 제약 추가가 까다로움)
|
||||
}
|
||||
// partner_requests 확장 컬럼 보장 (region, imageUrl, sortOrder, active)
|
||||
const prCols = await prisma.$queryRaw`PRAGMA table_info('partner_requests')`;
|
||||
const colHas = (name) => Array.isArray(prCols) && prCols.some((c) => (c.name || c.COLUMN_NAME) === name);
|
||||
if (!colHas('region')) {
|
||||
console.log("Adding missing column: partner_requests.region");
|
||||
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN region TEXT");
|
||||
}
|
||||
if (!colHas('imageUrl')) {
|
||||
console.log("Adding missing column: partner_requests.imageUrl");
|
||||
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN imageUrl TEXT");
|
||||
}
|
||||
if (!colHas('sortOrder')) {
|
||||
console.log("Adding missing column: partner_requests.sortOrder");
|
||||
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN sortOrder INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
if (!colHas('active')) {
|
||||
console.log("Adding missing column: partner_requests.active");
|
||||
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN active BOOLEAN NOT NULL DEFAULT 1");
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("SQLite schema ensure failed:", e);
|
||||
}
|
||||
await upsertRoles();
|
||||
const admin = await upsertAdmin();
|
||||
const categoryMap = await upsertCategories();
|
||||
await upsertViewTypes();
|
||||
await createRandomUsers(100);
|
||||
const randomUsers = await createRandomUsers(3);
|
||||
await seedRandomAttendanceForUsers(randomUsers, 10, 20);
|
||||
await removeNonPrimaryBoards();
|
||||
const boards = await upsertBoards(admin, categoryMap);
|
||||
await seedAdminAttendance(admin);
|
||||
await seedMainpageVisibleBoards(boards);
|
||||
await createPostsForAllBoards(boards, 100, admin);
|
||||
await seedPartnerShops();
|
||||
@@ -528,8 +656,22 @@ async function main() {
|
||||
{ name: "test", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
|
||||
{ name: "굿짐", category: "gym", latitude: 37.5600, longitude: 126.9820, address: "서울 중구" },
|
||||
];
|
||||
// 파트너 카테고리(PartnerCategory) 생성 및 매핑
|
||||
const partnerCategoryNames = Array.from(new Set(partners.map((p) => p.category).filter(Boolean)));
|
||||
const partnerCategoryMap = {};
|
||||
for (let i = 0; i < partnerCategoryNames.length; i++) {
|
||||
const name = partnerCategoryNames[i];
|
||||
const created = await prisma.partnerCategory.upsert({
|
||||
where: { name },
|
||||
update: { sortOrder: i + 1 },
|
||||
create: { name, sortOrder: i + 1 },
|
||||
});
|
||||
partnerCategoryMap[name] = created;
|
||||
}
|
||||
for (const p of partners) {
|
||||
await prisma.partner.upsert({ where: { name: p.name }, update: p, create: p });
|
||||
const categoryRef = p.category ? partnerCategoryMap[p.category] : null;
|
||||
const data = { ...p, categoryId: categoryRef ? categoryRef.id : null };
|
||||
await prisma.partner.upsert({ where: { name: p.name }, update: data, create: data });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BIN
public/uploads/1762439529544-pfdpsiv372l.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/uploads/1762439795788-41zbv74p6l9.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762444179265-fuj8zoahblc.jpg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
public/uploads/1762520156525-1dqijvt0rge.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/uploads/1762521903585-d2gxpaoocil.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
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 |
BIN
public/uploads/1762701326416-gknp8r0e4af.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762702099453-si2e8ubylu9.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/uploads/1762703335687-i85lpr0bgo.webp
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/uploads/1762704770941-j2nzhl8ww1.webp
Normal file
|
After Width: | Height: | Size: 279 KiB |
@@ -15,7 +15,7 @@ const navItems = [
|
||||
export default function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<aside className="w-56 shrink-0 border-r border-neutral-200 bg-white/80 backdrop-blur h-full">
|
||||
<aside className="w-56 shrink-0 border-r border-neutral-200 bg-white/90 backdrop-blur h-full">
|
||||
<div className="px-4 py-4 border-b border-neutral-200">
|
||||
<Link href="/admin" className="block text-lg font-bold text-neutral-900">관리자</Link>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,8 @@ export default function AdminBoardsPage() {
|
||||
const listTypes = useMemo(() => viewTypes.filter((v: any) => v.scope === 'list'), [viewTypes]);
|
||||
const defaultMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_default' || t.key === 'default')?.id ?? null), [mainTypes]);
|
||||
const defaultListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_default' || t.key === 'default')?.id ?? null), [listTypes]);
|
||||
const textMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_text')?.id ?? null), [mainTypes]);
|
||||
const textListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_text')?.id ?? null), [listTypes]);
|
||||
const categories = useMemo(() => {
|
||||
const merged = rawCategories.map((c: any) => ({ ...c, ...(dirtyCats[c.id] ?? {}) }));
|
||||
return merged.sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
@@ -237,7 +239,7 @@ export default function AdminBoardsPage() {
|
||||
return;
|
||||
}
|
||||
const sortOrder = (currentItems?.length ?? 0) + 1;
|
||||
await fetch(`/api/admin/boards`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, categoryId: catId === "uncat" ? null : catId, sortOrder, status: "active", mainPageViewTypeId: defaultMainTypeId, listViewTypeId: defaultListTypeId }) });
|
||||
await fetch(`/api/admin/boards`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, categoryId: catId === "uncat" ? null : catId, sortOrder, status: "active", mainPageViewTypeId: textMainTypeId, listViewTypeId: textListTypeId }) });
|
||||
await mutateBoards();
|
||||
}
|
||||
|
||||
@@ -436,12 +438,20 @@ export default function AdminBoardsPage() {
|
||||
|
||||
function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, mainTypes, listTypes, onAddType, defaultMainTypeId, defaultListTypeId }: { b: any; onDirty: (id: string, draft: any) => void; onDelete: () => void; allowMove?: boolean; categories?: any[]; onMove?: (toId: string) => void; mainTypes?: any[]; listTypes?: any[]; onAddType?: (scope: 'main'|'list') => Promise<string | null>; defaultMainTypeId?: string | null; defaultListTypeId?: string | null }) {
|
||||
const [edit, setEdit] = useState(b);
|
||||
const effectiveMainTypeId = edit.mainPageViewTypeId ?? defaultMainTypeId ?? '';
|
||||
const effectiveListTypeId = edit.listViewTypeId ?? defaultListTypeId ?? '';
|
||||
// 선택 가능 옵션에서 '기본' 타입은 제외
|
||||
const selectableMainTypes = (mainTypes ?? []).filter((t: any) => t.id !== defaultMainTypeId);
|
||||
const selectableListTypes = (listTypes ?? []).filter((t: any) => t.id !== defaultListTypeId);
|
||||
// 표시 값: 현재 값이 선택 가능 목록에 없으면 첫 번째 항목을 사용
|
||||
const effectiveMainTypeId = selectableMainTypes.some((t: any) => t.id === edit.mainPageViewTypeId)
|
||||
? edit.mainPageViewTypeId
|
||||
: (selectableMainTypes[0]?.id ?? '');
|
||||
const effectiveListTypeId = selectableListTypes.some((t: any) => t.id === edit.listViewTypeId)
|
||||
? edit.listViewTypeId
|
||||
: (selectableListTypes[0]?.id ?? '');
|
||||
return (
|
||||
<>
|
||||
<td className="px-3 py-2"><input className="h-9 w-full rounded-md border border-neutral-300 px-2 text-sm" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||
<td className="px-3 py-2"><input className="h-9 w-full rounded-md border border-neutral-300 px-2 text-sm" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||
<td className="px-3 py-2"><input className="h-9 w-full min-w-[160px] rounded-md border border-neutral-300 px-2 text-sm" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||
<td className="px-3 py-2"><input className="h-9 w-full min-w-[200px] rounded-md border border-neutral-300 px-2 text-sm" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<select
|
||||
className="h-9 rounded-md border border-neutral-300 px-2 text-sm"
|
||||
@@ -453,12 +463,11 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
|
||||
e.currentTarget.value = id ?? '';
|
||||
return;
|
||||
}
|
||||
const v = { ...edit, mainPageViewTypeId: e.target.value || null };
|
||||
const v = { ...edit, mainPageViewTypeId: e.target.value };
|
||||
setEdit(v); onDirty(b.id, v);
|
||||
}}
|
||||
>
|
||||
<option value="">(없음)</option>
|
||||
{(mainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||
{(selectableMainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||
<option value="__add__">+ 새 타입 추가…</option>
|
||||
</select>
|
||||
</td>
|
||||
@@ -473,12 +482,11 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
|
||||
e.currentTarget.value = id ?? '';
|
||||
return;
|
||||
}
|
||||
const v = { ...edit, listViewTypeId: e.target.value || null };
|
||||
const v = { ...edit, listViewTypeId: e.target.value };
|
||||
setEdit(v); onDirty(b.id, v);
|
||||
}}
|
||||
>
|
||||
<option value="">(없음)</option>
|
||||
{(listTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||
{(selectableListTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||
<option value="__add__">+ 새 타입 추가…</option>
|
||||
</select>
|
||||
</td>
|
||||
@@ -551,8 +559,8 @@ function CategoryHeaderContent({ g, onDirty }: { g: any; onDirty: (payload: any)
|
||||
return (
|
||||
<>
|
||||
<div className="w-10" />
|
||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} />
|
||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
|
||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48 min-w-[160px]" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} />
|
||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48 min-w-[200px]" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
|
||||
<div className="flex-1" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import AdminSidebar from "@/app/admin/AdminSidebar";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Admin | ASSM",
|
||||
};
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
// 서버에서 쿠키 기반 접근 제어 (미들웨어 보조)
|
||||
const h = await headers();
|
||||
const cookieHeader = h.get("cookie") || "";
|
||||
const uid = cookieHeader
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.find((pair) => pair.startsWith("uid="))
|
||||
?.split("=")[1];
|
||||
const isAdmin = cookieHeader
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.find((pair) => pair.startsWith("isAdmin="))
|
||||
?.split("=")[1];
|
||||
if (!uid) {
|
||||
redirect("/login");
|
||||
}
|
||||
if (isAdmin !== "1") {
|
||||
redirect("/");
|
||||
}
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-0px)] flex">
|
||||
<AdminSidebar />
|
||||
|
||||
@@ -7,8 +7,10 @@ const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export default function AdminPartnersPage() {
|
||||
const { data, mutate } = useSWR<{ partners: any[] }>("/api/admin/partners", fetcher);
|
||||
const { data: catData, mutate: mutateCategories } = useSWR<{ categories: any[] }>("/api/admin/partner-categories", fetcher);
|
||||
const partners = data?.partners ?? [];
|
||||
const [form, setForm] = useState({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
|
||||
const categories = catData?.categories ?? [];
|
||||
const [form, setForm] = useState({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" });
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const editFileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -62,8 +64,8 @@ export default function AdminPartnersPage() {
|
||||
|
||||
async function create() {
|
||||
// 필수값 검증: 이름/카테고리/위도/경도
|
||||
if (!form.name || !form.category || !String(form.latitude).trim() || !String(form.longitude).trim()) {
|
||||
alert("이름, 카테고리, 위도, 경도를 모두 입력해 주세요.");
|
||||
if (!form.name || !form.categoryId || !String(form.latitude).trim() || !String(form.longitude).trim()) {
|
||||
alert("이름, 카테고리, 위도, 경도를 모두 선택/입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
const lat = Number(form.latitude);
|
||||
@@ -72,12 +74,21 @@ export default function AdminPartnersPage() {
|
||||
alert("위도/경도는 숫자여야 합니다.");
|
||||
return;
|
||||
}
|
||||
const payload = { ...form, latitude: lat, longitude: lon } as any;
|
||||
const payload = { ...form, latitude: lat, longitude: lon, categoryId: form.categoryId } as any;
|
||||
const r = await fetch("/api/admin/partners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
||||
if (r.ok) {
|
||||
setForm({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
|
||||
setForm({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" });
|
||||
mutate();
|
||||
setShowCreateModal(false);
|
||||
} else {
|
||||
let msg = "저장에 실패했습니다.";
|
||||
try {
|
||||
const j = await r.json();
|
||||
msg = j?.message || j?.error || msg;
|
||||
if (r.status === 409 && j?.error === "duplicate_name") msg = "이미 존재하는 업체명입니다.";
|
||||
if (r.status === 400) msg = msg || "입력값을 확인해 주세요.";
|
||||
} catch {}
|
||||
alert(msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +118,12 @@ export default function AdminPartnersPage() {
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 3" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>카테고리</label>
|
||||
<input style={inputStyle} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} />
|
||||
<select style={inputStyle} value={form.categoryId} onChange={(e) => setForm({ ...form, categoryId: e.target.value })}>
|
||||
<option value="">(없음)</option>
|
||||
{categories.map((c: any) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 3" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
||||
@@ -178,7 +194,12 @@ export default function AdminPartnersPage() {
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 3" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>카테고리</label>
|
||||
<input style={inputStyle} value={editDraft?.category ?? ""} onChange={(e) => setEditDraft({ ...editDraft, category: e.target.value })} />
|
||||
<select style={inputStyle} value={editDraft?.categoryId ?? ""} onChange={(e) => setEditDraft({ ...editDraft, categoryId: e.target.value })}>
|
||||
<option value="">(없음)</option>
|
||||
{categories.map((c: any) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 3" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
||||
@@ -216,7 +237,23 @@ export default function AdminPartnersPage() {
|
||||
)}
|
||||
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||
<button
|
||||
onClick={async () => { if (!editingId) return; await fetch(`/api/admin/partners/${editingId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...editDraft }) }); setEditingId(null); setEditDraft(null); setShowEditModal(false); mutate(); }}
|
||||
onClick={async () => {
|
||||
if (!editingId) return;
|
||||
const resp = await fetch(`/api/admin/partners/${editingId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...editDraft, categoryId: (editDraft?.categoryId || null) }) });
|
||||
if (!resp.ok) {
|
||||
let msg = "저장에 실패했습니다.";
|
||||
try {
|
||||
const j = await resp.json();
|
||||
msg = j?.message || j?.error || msg;
|
||||
} catch {}
|
||||
alert(msg);
|
||||
return;
|
||||
}
|
||||
setEditingId(null);
|
||||
setEditDraft(null);
|
||||
setShowEditModal(false);
|
||||
mutate();
|
||||
}}
|
||||
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
||||
>
|
||||
저장
|
||||
@@ -250,14 +287,14 @@ export default function AdminPartnersPage() {
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<div><strong>{p.name}</strong> <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>{p.category}</span></div>
|
||||
<div><strong>{p.name}</strong> {p.categoryRef ? <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>[{p.categoryRef.name}]</span> : null}</div>
|
||||
<div style={{ fontSize: 12, opacity: .7 }}>{p.address || "(주소 없음)"}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, opacity: .7 }}>위도 {p.latitude}</div>
|
||||
<div style={{ fontSize: 12, opacity: .7 }}>경도 {p.longitude}</div>
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={() => { setEditingId(p.id); setEditDraft({ name: p.name, category: p.category, latitude: String(p.latitude), longitude: String(p.longitude), address: p.address ?? "", imageUrl: p.imageUrl ?? "", sortOrder: p.sortOrder ?? 0 }); setShowEditModal(true); }}
|
||||
onClick={() => { setEditingId(p.id); setEditDraft({ name: p.name, categoryId: p.categoryId ?? "", latitude: String(p.latitude), longitude: String(p.longitude), address: p.address ?? "", imageUrl: p.imageUrl ?? "", sortOrder: p.sortOrder ?? 0 }); setShowEditModal(true); }}
|
||||
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
||||
>
|
||||
수정
|
||||
@@ -288,8 +325,49 @@ export default function AdminPartnersPage() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div>
|
||||
<hr style={{ margin: "24px 0" }} />
|
||||
<h2 className="text-lg font-bold mb-2">카테고리 관리</h2>
|
||||
<CategoryManager categories={categories} onChanged={mutateCategories} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function CategoryManager({ categories, onChanged }: { categories: any[]; onChanged: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
return (
|
||||
<div className="border border-neutral-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<input
|
||||
placeholder="카테고리 이름"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="h-9 px-3 rounded-md border border-neutral-300 flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => { if (!name.trim()) return; const r = await fetch("/api/admin/partner-categories", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: name.trim() }) }); if (r.ok) { setName(""); onChanged(); } else { try { const j = await r.json(); alert(j?.message || j?.error || "생성 실패"); } catch { alert("생성 실패"); } } }}
|
||||
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{categories.map((c: any) => (
|
||||
<li key={c.id} className="flex items-center gap-2 px-2 py-1 rounded-full border border-neutral-300 text-sm">
|
||||
<span>{c.name}</span>
|
||||
<button
|
||||
title="삭제"
|
||||
onClick={async () => { const r = await fetch(`/api/admin/partner-categories/${c.id}`, { method: "DELETE" }); if (r.ok) onChanged(); else { try { const j = await r.json(); alert(j?.message || j?.error || "삭제 실패"); } catch { alert("삭제 실패"); } } }}
|
||||
className="px-2 h-6 rounded-md border border-red-200 text-red-600 hover:bg-red-100 hover:border-red-300 hover:text-red-700"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function AdminUsersPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[#f6f4f4] border-b border-[#e6e6e6]">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">ID</th>
|
||||
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">닉네임</th>
|
||||
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">이름</th>
|
||||
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">전화</th>
|
||||
@@ -90,6 +91,7 @@ function Row({ u, onChanged }: { u: any; onChanged: () => void }) {
|
||||
const allRoles = ["admin", "editor", "user"] as const;
|
||||
return (
|
||||
<tr className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-2 text-left tabular-nums">{u.userId}</td>
|
||||
<td className="px-4 py-2 text-left">{u.nickname}</td>
|
||||
<td className="px-4 py-2 text-left">{u.name}</td>
|
||||
<td className="px-4 py-2 text-left">{u.phone}</td>
|
||||
|
||||
28
src/app/api/admin/partner-categories/[id]/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await context.params;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const data: any = {};
|
||||
for (const k of ["name", "sortOrder"]) if (k in body) data[k] = body[k];
|
||||
try {
|
||||
const category = await prisma.partnerCategory.update({ where: { id }, data });
|
||||
return NextResponse.json({ category });
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'P2002') return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 카테고리입니다.' }, { status: 409 });
|
||||
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await context.params;
|
||||
try {
|
||||
await prisma.partnerCategory.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: 'category_in_use', message: '해당 카테고리를 사용하는 제휴업체가 있습니다.' }, { status: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
25
src/app/api/admin/partner-categories/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function GET() {
|
||||
const categories = await prisma.partnerCategory.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }] });
|
||||
return NextResponse.json({ categories });
|
||||
}
|
||||
|
||||
const createSchema = z.object({ name: z.string().min(1), sortOrder: z.coerce.number().int().optional() });
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
try {
|
||||
const category = await prisma.partnerCategory.create({ data: { name: parsed.data.name, sortOrder: parsed.data.sortOrder ?? 0 } });
|
||||
return NextResponse.json({ category }, { status: 201 });
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'P2002') return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 카테고리입니다.' }, { status: 409 });
|
||||
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
|
||||
for (const k of ["region", "name", "address", "imageUrl", "active", "sortOrder"]) {
|
||||
if (k in body) data[k] = body[k];
|
||||
}
|
||||
const item = await prisma.partnerShop.update({ where: { id }, data });
|
||||
const item = await prisma.partnerRequest.update({ where: { id }, data });
|
||||
return NextResponse.json({ item });
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await context.params;
|
||||
await prisma.partnerShop.delete({ where: { id } });
|
||||
await prisma.partnerRequest.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@ import { z } from "zod";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
export async function GET() {
|
||||
const items = await prisma.partnerShop.findMany({ orderBy: [{ active: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }] });
|
||||
return NextResponse.json({ items });
|
||||
const rows = await prisma.partnerRequest.findMany({
|
||||
where: { status: "approved" },
|
||||
orderBy: [{ active: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
select: { id: true, region: true, name: true, address: true, imageUrl: true, sortOrder: true, active: true },
|
||||
});
|
||||
return NextResponse.json({ items: rows });
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
@@ -21,7 +25,8 @@ export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const item = await prisma.partnerShop.create({ data: parsed.data as Prisma.PartnerShopCreateInput });
|
||||
// 통합: 승인된 레코드로 등록
|
||||
const item = await prisma.partnerRequest.create({ data: { ...(parsed.data as any), status: "approved" } });
|
||||
return NextResponse.json({ item }, { status: 201 });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,19 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
|
||||
const { id } = await context.params;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const data: any = {};
|
||||
for (const k of ["name", "category", "latitude", "longitude", "address", "imageUrl", "sortOrder"]) {
|
||||
for (const k of ["name", "category", "latitude", "longitude", "address", "imageUrl", "sortOrder", "categoryId"]) {
|
||||
if (k in body) data[k] = body[k];
|
||||
}
|
||||
if (typeof data.latitude !== "undefined") data.latitude = Number(data.latitude);
|
||||
if (typeof data.longitude !== "undefined") data.longitude = Number(data.longitude);
|
||||
if (typeof data.sortOrder !== "undefined") data.sortOrder = Number(data.sortOrder);
|
||||
// categoryId가 들어왔고 category 문자열이 비어있으면 카테고리명으로 채움
|
||||
if (typeof data.categoryId !== "undefined" && (typeof data.category === "undefined" || data.category === null)) {
|
||||
if (data.categoryId) {
|
||||
const cat = await prisma.partnerCategory.findUnique({ where: { id: String(data.categoryId) } });
|
||||
if (cat) data.category = cat.name;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const partner = await prisma.partner.update({ where: { id }, data });
|
||||
return NextResponse.json({ partner });
|
||||
|
||||
@@ -4,19 +4,19 @@ import { z } from "zod";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// 정렬용 컬럼(sortOrder)이 있는 경우 우선 사용
|
||||
const partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }] });
|
||||
// 카테고리 조인 포함
|
||||
const partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], include: { categoryRef: true } });
|
||||
return NextResponse.json({ partners });
|
||||
} catch (_) {
|
||||
// 컬럼이 아직 DB에 없으면 createdAt 기준으로 폴백
|
||||
const partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" } });
|
||||
const partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" }, include: { categoryRef: true } });
|
||||
return NextResponse.json({ partners });
|
||||
}
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
category: z.string().min(1),
|
||||
category: z.string().min(1).optional(),
|
||||
latitude: z.coerce.number(),
|
||||
longitude: z.coerce.number(),
|
||||
address: z.string().min(1).optional(),
|
||||
@@ -27,14 +27,28 @@ const createSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
sortOrder: z.coerce.number().int().optional(),
|
||||
categoryId: z.string().min(1),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const partner = await prisma.partner.create({ data: parsed.data as any });
|
||||
try {
|
||||
const { categoryId } = parsed.data as any;
|
||||
const cat = await prisma.partnerCategory.findUnique({ where: { id: categoryId } });
|
||||
if (!cat) return NextResponse.json({ error: 'invalid_category', message: '유효하지 않은 카테고리입니다.' }, { status: 400 });
|
||||
const data: any = { ...parsed.data };
|
||||
if (!data.category) data.category = cat.name;
|
||||
const partner = await prisma.partner.create({ data });
|
||||
return NextResponse.json({ partner }, { status: 201 });
|
||||
} catch (e: any) {
|
||||
// Unique name 에러 처리
|
||||
if (e?.code === 'P2002') {
|
||||
return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 업체명입니다.' }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
77
src/app/api/attendance/me-stats/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getUserIdFromRequest } from "@/lib/auth";
|
||||
|
||||
function toYmdUTC(d: Date): string {
|
||||
const yy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${yy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const userId = getUserIdFromRequest(req);
|
||||
if (!userId) return NextResponse.json({ total: 0, currentStreak: 0, maxStreak: 0 });
|
||||
|
||||
// 총 출석일
|
||||
const total = await prisma.attendance.count({ where: { userId } });
|
||||
|
||||
// 모든 출석일(UTC 자정 기준) 가져와서 streak 계산
|
||||
const rows = await prisma.attendance.findMany({
|
||||
where: { userId },
|
||||
select: { date: true },
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
const days = Array.from(new Set(rows.map((r) => toYmdUTC(new Date(r.date))))); // unique, asc
|
||||
|
||||
// 현재 연속 출석
|
||||
let currentStreak = 0;
|
||||
if (days.length > 0) {
|
||||
const set = new Set(days);
|
||||
const now = new Date();
|
||||
// 로컬 날짜(사용자 체감 날짜)를 UTC 자정으로 정규화하여 비교
|
||||
let cursor = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0));
|
||||
let tried = 0;
|
||||
while (tried < 2 && currentStreak === 0) {
|
||||
const startYmd = toYmdUTC(cursor);
|
||||
if (!set.has(startYmd)) {
|
||||
cursor.setUTCDate(cursor.getUTCDate() - 1);
|
||||
tried += 1;
|
||||
continue;
|
||||
}
|
||||
while (true) {
|
||||
const ymd = toYmdUTC(cursor);
|
||||
if (set.has(ymd)) {
|
||||
currentStreak += 1;
|
||||
cursor.setUTCDate(cursor.getUTCDate() - 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 최대 연속 출석
|
||||
let maxStreak = 0;
|
||||
if (days.length > 0) {
|
||||
// scan through sorted list
|
||||
let localMax = 1;
|
||||
for (let i = 1; i < days.length; i++) {
|
||||
const prev = new Date(days[i - 1] + "T00:00:00.000Z");
|
||||
const cur = new Date(days[i] + "T00:00:00.000Z");
|
||||
const diff = (cur.getTime() - prev.getTime()) / (24 * 60 * 60 * 1000);
|
||||
if (diff === 1) {
|
||||
localMax += 1;
|
||||
} else if (diff > 1) {
|
||||
if (localMax > maxStreak) maxStreak = localMax;
|
||||
localMax = 1;
|
||||
}
|
||||
}
|
||||
if (localMax > maxStreak) maxStreak = localMax;
|
||||
}
|
||||
|
||||
return NextResponse.json({ total, currentStreak, maxStreak });
|
||||
}
|
||||
|
||||
|
||||
116
src/app/api/attendance/rankings/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
function toYmdUTC(d: Date): string {
|
||||
const yy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${yy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const only = url.searchParams.get("only");
|
||||
|
||||
// 최대 연속 출석 상위 (Top 20, 전체 이력 기준)
|
||||
async function computeStreakTop() {
|
||||
// 전체 이력 기준으로 사용자별 최대 연속 출석을 계산
|
||||
const rows = await prisma.attendance.findMany({
|
||||
select: { userId: true, date: true },
|
||||
orderBy: [{ userId: "asc" }, { date: "asc" }],
|
||||
});
|
||||
const maxByUser = new Map<string, number>();
|
||||
let currentUserId: string | null = null;
|
||||
let lastDayMs: number | null = null;
|
||||
let current = 0;
|
||||
let maxStreak = 0;
|
||||
const commit = () => {
|
||||
if (currentUserId) {
|
||||
maxByUser.set(currentUserId, Math.max(maxByUser.get(currentUserId) ?? 0, maxStreak));
|
||||
}
|
||||
};
|
||||
for (const r of rows) {
|
||||
if (r.userId !== currentUserId) {
|
||||
// flush previous
|
||||
if (currentUserId !== null) commit();
|
||||
// reset for new user
|
||||
currentUserId = r.userId;
|
||||
lastDayMs = null;
|
||||
current = 0;
|
||||
maxStreak = 0;
|
||||
}
|
||||
// UTC 일 단위로 비교
|
||||
const ymd = toYmdUTC(new Date(r.date));
|
||||
const ms = Date.parse(`${ymd}T00:00:00.000Z`);
|
||||
if (lastDayMs === null) {
|
||||
current = 1;
|
||||
} else {
|
||||
const diffDays = Math.round((ms - lastDayMs) / (24 * 60 * 60 * 1000));
|
||||
if (diffDays === 1) {
|
||||
current += 1;
|
||||
} else if (diffDays > 0) {
|
||||
current = 1;
|
||||
} else {
|
||||
// 동일/역순은 이례적이지만 안전하게 스킵
|
||||
current = Math.max(current, 1);
|
||||
}
|
||||
}
|
||||
if (current > maxStreak) maxStreak = current;
|
||||
lastDayMs = ms;
|
||||
}
|
||||
// flush last
|
||||
if (currentUserId !== null) commit();
|
||||
|
||||
const topStreak = Array.from(maxByUser.entries())
|
||||
.map(([userId, streak]) => ({ userId, streak }))
|
||||
.sort((a, b) => b.streak - a.streak)
|
||||
.slice(0, 20);
|
||||
const streakUserIds = topStreak.map((s) => s.userId);
|
||||
const streakUsers = await prisma.user.findMany({
|
||||
where: { userId: { in: streakUserIds } },
|
||||
select: { userId: true, nickname: true, profileImage: true, grade: true },
|
||||
});
|
||||
const streakMeta = new Map(streakUsers.map((u) => [u.userId, u]));
|
||||
const streak = topStreak.map((s) => ({
|
||||
userId: s.userId,
|
||||
nickname: streakMeta.get(s.userId)?.nickname ?? "회원",
|
||||
streak: s.streak,
|
||||
profileImage: streakMeta.get(s.userId)?.profileImage ?? null,
|
||||
grade: streakMeta.get(s.userId)?.grade ?? 0,
|
||||
}));
|
||||
return streak;
|
||||
}
|
||||
|
||||
if (only === "streak") {
|
||||
const streak = await computeStreakTop();
|
||||
return NextResponse.json({ streak });
|
||||
}
|
||||
|
||||
// 전체 출석 누적 상위 (Top 10)
|
||||
const overallGroups = await prisma.attendance.groupBy({
|
||||
by: ["userId"],
|
||||
_count: { date: true },
|
||||
orderBy: { _count: { date: "desc" } },
|
||||
take: 20,
|
||||
});
|
||||
const overallUserIds = overallGroups.map((g) => g.userId);
|
||||
const overallUsers = await prisma.user.findMany({
|
||||
where: { userId: { in: overallUserIds } },
|
||||
select: { userId: true, nickname: true, profileImage: true, grade: true },
|
||||
});
|
||||
const userMeta = new Map(overallUsers.map((u) => [u.userId, u]));
|
||||
const overall = overallGroups.map((g) => ({
|
||||
userId: g.userId,
|
||||
nickname: userMeta.get(g.userId)?.nickname ?? "회원",
|
||||
count: g._count.date ?? 0,
|
||||
profileImage: userMeta.get(g.userId)?.profileImage ?? null,
|
||||
grade: userMeta.get(g.userId)?.grade ?? 0,
|
||||
}));
|
||||
|
||||
const streak = await computeStreakTop();
|
||||
|
||||
return NextResponse.json({ overall, streak });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getUserIdFromRequest } from "@/lib/auth";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const userId = getUserIdFromRequest(req);
|
||||
if (!userId) return NextResponse.json({ today: null, count: 0 });
|
||||
if (!userId) return NextResponse.json({ today: null, count: 0, days: [] });
|
||||
const url = new URL(req.url);
|
||||
const year = url.searchParams.get("year");
|
||||
const month = url.searchParams.get("month"); // 1-12
|
||||
const start = new Date(); start.setHours(0,0,0,0);
|
||||
const end = new Date(); end.setHours(23,59,59,999);
|
||||
const today = await prisma.pointTransaction.findFirst({
|
||||
where: { userId, reason: "attendance", createdAt: { gte: start, lte: end } },
|
||||
const today = await prisma.attendance.findFirst({
|
||||
where: { userId, date: { gte: start, lte: end } },
|
||||
});
|
||||
const count = await prisma.pointTransaction.count({ where: { userId, reason: "attendance" } });
|
||||
const count = await prisma.attendance.count({ where: { userId } });
|
||||
// 월별 출석 일자 목록
|
||||
if (year && month) {
|
||||
const y = parseInt(year, 10);
|
||||
const m = parseInt(month, 10);
|
||||
if (!isNaN(y) && !isNaN(m) && m >= 1 && m <= 12) {
|
||||
const firstDay = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0, 0));
|
||||
const lastDay = new Date(Date.UTC(y, m, 0, 23, 59, 59, 999));
|
||||
const records = await prisma.attendance.findMany({
|
||||
where: { userId, date: { gte: firstDay, lte: lastDay } },
|
||||
select: { date: true },
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
const days = records.map(r => {
|
||||
const d = new Date(r.date);
|
||||
const yy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${yy}-${mm}-${dd}`;
|
||||
});
|
||||
return NextResponse.json({ today: !!today, count, days });
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ today: !!today, count });
|
||||
}
|
||||
|
||||
@@ -19,9 +45,12 @@ export async function POST(req: Request) {
|
||||
if (!userId) return NextResponse.json({ error: "login required" }, { status: 401 });
|
||||
const start = new Date(); start.setHours(0,0,0,0);
|
||||
const end = new Date(); end.setHours(23,59,59,999);
|
||||
const exists = await prisma.pointTransaction.findFirst({ where: { userId, reason: "attendance", createdAt: { gte: start, lte: end } } });
|
||||
const exists = await prisma.attendance.findFirst({ where: { userId, date: { gte: start, lte: end } } });
|
||||
if (exists) return NextResponse.json({ ok: true, duplicated: true });
|
||||
await prisma.pointTransaction.create({ data: { userId, amount: 10, reason: "attendance" } });
|
||||
// normalize to UTC midnight
|
||||
const now = new Date();
|
||||
const normalized = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0));
|
||||
await prisma.attendance.create({ data: { userId, date: normalized } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
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 } });
|
||||
}
|
||||
|
||||
@@ -5,13 +5,33 @@ import { getUserIdFromRequest } from "@/lib/auth";
|
||||
export async function GET(req: Request) {
|
||||
const userId = getUserIdFromRequest(req);
|
||||
if (!userId) return NextResponse.json({ permissions: [] });
|
||||
const roles = await prisma.userRole.findMany({ where: { userId }, select: { roleId: true } });
|
||||
if (roles.length === 0) return NextResponse.json({ permissions: [] });
|
||||
const roleIds = roles.map((r) => r.roleId);
|
||||
const permissions = await prisma.rolePermission.findMany({
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { userId },
|
||||
select: {
|
||||
authLevel: true,
|
||||
userRoles: { select: { roleId: true } },
|
||||
},
|
||||
});
|
||||
if (!user) return NextResponse.json({ permissions: [] });
|
||||
|
||||
const roleIds = user.userRoles.map((r) => r.roleId);
|
||||
const rolePermissions =
|
||||
roleIds.length > 0
|
||||
? await prisma.rolePermission.findMany({
|
||||
where: { roleId: { in: roleIds }, allowed: true },
|
||||
select: { resource: true, action: true },
|
||||
});
|
||||
})
|
||||
: [];
|
||||
|
||||
const hasAdminPerm = rolePermissions.some(
|
||||
(perm) => perm.resource === "ADMIN" && perm.action === "ADMINISTER"
|
||||
);
|
||||
const permissions =
|
||||
user.authLevel === "ADMIN" && !hasAdminPerm
|
||||
? [{ resource: "ADMIN" as const, action: "ADMINISTER" as const }, ...rolePermissions]
|
||||
: rolePermissions;
|
||||
|
||||
return NextResponse.json({ permissions });
|
||||
}
|
||||
|
||||
|
||||
@@ -8,17 +8,41 @@ 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, phone, birth, password } = parsed.data;
|
||||
const exists = await prisma.user.findFirst({ where: { OR: [{ nickname }, { phone }] } });
|
||||
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: {
|
||||
userId: nickname,
|
||||
nickname,
|
||||
name,
|
||||
phone,
|
||||
birth: new Date(birth),
|
||||
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,16 +29,59 @@ 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;
|
||||
const userRoles = await prisma.userRole.findMany({
|
||||
where: { userId: user.userId },
|
||||
select: { roleId: true },
|
||||
});
|
||||
if (userRoles.length > 0) {
|
||||
const roleIds = userRoles.map((r) => r.roleId);
|
||||
const hasAdmin = await prisma.rolePermission.findFirst({
|
||||
where: {
|
||||
roleId: { in: roleIds },
|
||||
resource: "ADMIN",
|
||||
action: "ADMINISTER",
|
||||
allowed: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
isAdmin = !!hasAdmin;
|
||||
}
|
||||
// 추가 안전장치: 사용자 레코드의 authLevel이 ADMIN이면 관리자 취급
|
||||
if (!isAdmin && user.authLevel === "ADMIN") {
|
||||
isAdmin = true;
|
||||
}
|
||||
|
||||
const res = NextResponse.json({ ok: true, user: { userId: user.userId, nickname: user.nickname } });
|
||||
// HTTPS 요청에서만 Secure 속성 부여 (HTTP 환경에서는 생략하여 로컬 start에서도 동작)
|
||||
let secureAttr = "";
|
||||
try {
|
||||
const isHttps = new URL(req.url).protocol === "https:";
|
||||
secureAttr = isHttps ? "; Secure" : "";
|
||||
} catch {
|
||||
secureAttr = "";
|
||||
}
|
||||
res.headers.append(
|
||||
"Set-Cookie",
|
||||
`uid=${encodeURIComponent(user.userId)}; Path=/; HttpOnly; SameSite=Lax`
|
||||
`uid=${encodeURIComponent(user.userId)}; Path=/; HttpOnly; SameSite=Lax${secureAttr}`
|
||||
);
|
||||
res.headers.append(
|
||||
"Set-Cookie",
|
||||
`isAdmin=${isAdmin ? "1" : "0"}; Path=/; HttpOnly; SameSite=Lax${secureAttr}`
|
||||
);
|
||||
return res;
|
||||
}
|
||||
@@ -46,6 +89,7 @@ export async function POST(req: Request) {
|
||||
export async function DELETE() {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.headers.append("Set-Cookie", `uid=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`);
|
||||
res.headers.append("Set-Cookie", `isAdmin=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`);
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import prisma from "@/lib/prisma";
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const category = searchParams.get("category"); // slug or id
|
||||
const where: any = {};
|
||||
const where: any = { status: "active" };
|
||||
if (category) {
|
||||
if (category.length === 25 || category.length === 24) {
|
||||
where.categoryId = category;
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
31
src/app/api/me/profile-image/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getUserIdOrAdmin } from "@/lib/auth";
|
||||
|
||||
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 url: string | null = body?.url ?? null;
|
||||
if (url !== null && typeof url !== "string") {
|
||||
return NextResponse.json({ error: "invalid url" }, { status: 400 });
|
||||
}
|
||||
// 간단 검증: 내부 업로드 경로 또는 http(s) 허용
|
||||
if (url) {
|
||||
const ok = url.startsWith("/uploads/") || url.startsWith("http://") || url.startsWith("https://");
|
||||
if (!ok) return NextResponse.json({ error: "invalid url" }, { status: 400 });
|
||||
if (url.length > 1000) return NextResponse.json({ error: "url too long" }, { status: 400 });
|
||||
}
|
||||
const user = await prisma.user.update({
|
||||
where: { userId },
|
||||
data: { profileImage: url || null },
|
||||
select: { userId: true, profileImage: true },
|
||||
});
|
||||
return NextResponse.json({ ok: true, user });
|
||||
} 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 });
|
||||
}
|
||||
|
||||
|
||||
9
src/app/api/partner-categories/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const categories = await prisma.partnerCategory.findMany({ orderBy: [{ sortOrder: "asc" }, { name: "asc" }] });
|
||||
return NextResponse.json({ categories });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@ import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const items = await prisma.partnerShop.findMany({
|
||||
where: { active: true },
|
||||
// 통합: 승인된 파트너 요청을 메인 노출용으로 사용
|
||||
const rows = await prisma.partnerRequest.findMany({
|
||||
where: { status: "approved", active: true },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
select: { id: true, region: true, name: true, address: true, imageUrl: true, sortOrder: true, active: true, category: true },
|
||||
});
|
||||
const items = rows.map((r) => ({ id: r.id, region: r.region || "", name: r.name, address: r.address || "", imageUrl: r.imageUrl || "", sortOrder: r.sortOrder, active: r.active, category: r.category }));
|
||||
return NextResponse.json({ items });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
function haversine(lat1: number, lon1: number, lat2: number, lon2: number) {
|
||||
const toRad = (v: number) => (v * Math.PI) / 180;
|
||||
const R = 6371; // km
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const lat = Number(searchParams.get("lat"));
|
||||
const lon = Number(searchParams.get("lon"));
|
||||
const category = searchParams.get("category") || undefined;
|
||||
const radius = Number(searchParams.get("radius")) || 10; // km
|
||||
const where = category ? { category } : {};
|
||||
const categoryId = searchParams.get("categoryId") || undefined;
|
||||
let where: any = {};
|
||||
if (categoryId) {
|
||||
// 카테고리 ID 매칭 + 과거 데이터 호환(문자열 category명 매칭)
|
||||
let catName: string | undefined;
|
||||
try {
|
||||
const cat = await prisma.partnerCategory.findUnique({ where: { id: categoryId }, select: { name: true } });
|
||||
catName = cat?.name;
|
||||
} catch {}
|
||||
where = catName ? { OR: [{ categoryId }, { category: catName }] } : { categoryId };
|
||||
} else if (category) {
|
||||
where = { category };
|
||||
}
|
||||
const partners = await prisma.partner.findMany({ where, orderBy: { createdAt: "desc" } });
|
||||
const withDistance = isFinite(lat) && isFinite(lon)
|
||||
? partners.map((p) => ({ ...p, distance: haversine(lat, lon, p.latitude, p.longitude) })).filter((p) => p.distance <= radius)
|
||||
: partners.map((p) => ({ ...p, distance: null }));
|
||||
withDistance.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0));
|
||||
return NextResponse.json({ partners: withDistance });
|
||||
return NextResponse.json({ partners });
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
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: comment.isSecret ? "비밀댓글입니다." : comment.content,
|
||||
content: canViewSecret ? comment.content : "비밀댓글입니다.",
|
||||
isAnonymous: comment.isAnonymous,
|
||||
isSecret: comment.isSecret,
|
||||
author: comment.author ? {
|
||||
author: comment.author
|
||||
? {
|
||||
userId: comment.author.userId,
|
||||
nickname: comment.author.nickname,
|
||||
profileImage: comment.author.profileImage,
|
||||
} : null,
|
||||
}
|
||||
: 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 });
|
||||
|
||||
@@ -1,18 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({ userId: z.string().optional(), clientHash: z.string().optional() }).refine(
|
||||
(d) => !!d.userId || !!d.clientHash,
|
||||
{ message: "Provide userId or clientHash" }
|
||||
);
|
||||
import { getUserIdFromRequest } from "@/lib/auth";
|
||||
import crypto from "crypto";
|
||||
|
||||
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await context.params;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const parsed = schema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const { userId, clientHash } = parsed.data;
|
||||
// 1) 사용자 식별 시도: 쿠키/헤더에서 userId 우선
|
||||
let userId = getUserIdFromRequest(req);
|
||||
|
||||
// 2) 바디에서 clientHash 수용(클라이언트가 보낼 수 있음)
|
||||
let clientHash: string | null = null;
|
||||
// JSON 우선
|
||||
const jsonBody = await req
|
||||
.json()
|
||||
.catch(() => null);
|
||||
if (jsonBody && typeof jsonBody === "object") {
|
||||
if (!userId && typeof jsonBody.userId === "string" && jsonBody.userId.length > 0) {
|
||||
userId = jsonBody.userId;
|
||||
}
|
||||
if (typeof jsonBody.clientHash === "string" && jsonBody.clientHash.length > 0) {
|
||||
clientHash = jsonBody.clientHash;
|
||||
}
|
||||
} else {
|
||||
// form 제출도 허용 (빈 폼일 수 있음)
|
||||
const form = await req
|
||||
.formData()
|
||||
.catch(() => null);
|
||||
if (form) {
|
||||
const formUserId = form.get("userId");
|
||||
const formClientHash = form.get("clientHash");
|
||||
if (!userId && typeof formUserId === "string" && formUserId.length > 0) {
|
||||
userId = formUserId;
|
||||
}
|
||||
if (typeof formClientHash === "string" && formClientHash.length > 0) {
|
||||
clientHash = formClientHash;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 둘 다 없으면 서버에서 익명 고유 clientHash 생성(IP + UA 기반)
|
||||
if (!userId && !clientHash) {
|
||||
const ua = req.headers.get("user-agent") || "";
|
||||
// x-forwarded-for 첫번째가 원 IP 가정
|
||||
const ip =
|
||||
(req.headers.get("x-forwarded-for") || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())[0] ||
|
||||
req.headers.get("x-real-ip") ||
|
||||
"";
|
||||
const raw = `${ip}::${ua}`;
|
||||
clientHash = crypto.createHash("sha256").update(raw).digest("hex").slice(0, 32);
|
||||
}
|
||||
|
||||
const existing = await prisma.reaction.findFirst({
|
||||
where: { postId: id, type: "RECOMMEND", userId: userId ?? null, clientHash: clientHash ?? null },
|
||||
@@ -27,6 +65,19 @@ export async function POST(req: Request, context: { params: Promise<{ id: string
|
||||
});
|
||||
}
|
||||
|
||||
// 폼 제출의 경우, JSON 대신 원래 페이지로 리다이렉트
|
||||
// jsonBody가 없고(formData 경로였거나 파싱 실패), 브라우저에서 온 요청으로 가정
|
||||
if (!jsonBody) {
|
||||
const referer = req.headers.get("referer");
|
||||
if (referer) {
|
||||
return NextResponse.redirect(referer);
|
||||
}
|
||||
const baseUrl = new URL(req.url);
|
||||
baseUrl.pathname = `/posts/${id}`;
|
||||
baseUrl.search = "";
|
||||
return NextResponse.redirect(baseUrl.toString());
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ 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 } },
|
||||
stat: { select: { views: true, recommendCount: true, commentsCount: true } },
|
||||
},
|
||||
});
|
||||
if (!post) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
@@ -5,11 +5,81 @@ import { getUserIdFromRequest } from "@/lib/auth";
|
||||
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await context.params;
|
||||
const userId = getUserIdFromRequest(req);
|
||||
const ip = req.headers.get("x-forwarded-for") || undefined;
|
||||
// x-forwarded-for는 다중 IP가 올 수 있음 -> 첫 번째(실제 클라이언트)만 사용
|
||||
const forwardedFor = req.headers.get("x-forwarded-for") || undefined;
|
||||
const ip = forwardedFor ? forwardedFor.split(",")[0]?.trim() || undefined : undefined;
|
||||
const userAgent = req.headers.get("user-agent") || undefined;
|
||||
await prisma.postViewLog.create({ data: { postId: id, userId: userId ?? null, ip, userAgent } });
|
||||
await prisma.postStat.upsert({ where: { postId: id }, update: { views: { increment: 1 } }, create: { postId: id, views: 1 } });
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
// 중복 방지: 로그인 사용자는 userId로, 비로그인은 (ip + userAgent) 조합으로 1회만 카운트
|
||||
const orConditions: any[] = [];
|
||||
if (userId) {
|
||||
orConditions.push({ userId });
|
||||
}
|
||||
// 비로그인 식별: 가능한 한 많은 신호를 사용 (ip+UA 우선, 단일 ip 또는 단일 UA로도 보수적으로 차단)
|
||||
if (!userId) {
|
||||
if (ip && userAgent) {
|
||||
orConditions.push({ userId: null, ip, userAgent });
|
||||
} else if (ip) {
|
||||
orConditions.push({ userId: null, ip, userAgent: null });
|
||||
} else if (userAgent) {
|
||||
orConditions.push({ userId: null, ip: null, userAgent });
|
||||
}
|
||||
}
|
||||
|
||||
let counted = false;
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (orConditions.length > 0) {
|
||||
const exists = await tx.postViewLog.findFirst({
|
||||
where: {
|
||||
postId: id,
|
||||
OR: orConditions,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 식별 정보가 전혀 없을 때는 안전을 위해 카운트하지 않음
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.postViewLog.create({
|
||||
data: {
|
||||
postId: id,
|
||||
userId: userId ?? null,
|
||||
ip: ip ?? null,
|
||||
userAgent: userAgent ?? null,
|
||||
},
|
||||
});
|
||||
await tx.postStat.upsert({
|
||||
where: { postId: id },
|
||||
update: { views: { increment: 1 } },
|
||||
create: { postId: id, views: 1 },
|
||||
});
|
||||
|
||||
// 일일 조회수 업데이트 (오늘 날짜)
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
await tx.dailyPostView.upsert({
|
||||
where: {
|
||||
postId_date: {
|
||||
postId: id,
|
||||
date: today,
|
||||
},
|
||||
},
|
||||
update: { viewCount: { increment: 1 } },
|
||||
create: {
|
||||
postId: id,
|
||||
date: today,
|
||||
viewCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
counted = true;
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, counted });
|
||||
}
|
||||
|
||||
|
||||
|
||||
92
src/app/api/posts/popular/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const boardId = searchParams.get("boardId");
|
||||
const period = searchParams.get("period") || "daily"; // daily | weekly
|
||||
|
||||
// 날짜 범위 계산
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
||||
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
|
||||
const startOfWeek = new Date(now);
|
||||
startOfWeek.setDate(now.getDate() - 7);
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
const dateFilter = period === "daily"
|
||||
? {
|
||||
date: {
|
||||
gte: startOfToday,
|
||||
lte: endOfToday,
|
||||
}
|
||||
}
|
||||
: { date: { gte: startOfWeek } };
|
||||
|
||||
// 일일 조회수 테이블에서 조회수 합계 계산
|
||||
const dailyViews = await prisma.dailyPostView.groupBy({
|
||||
by: ["postId"],
|
||||
where: dateFilter,
|
||||
_sum: {
|
||||
viewCount: true,
|
||||
},
|
||||
orderBy: {
|
||||
_sum: {
|
||||
viewCount: "desc",
|
||||
},
|
||||
},
|
||||
take: 20, // 조회수 상위 20개만 가져와서 게시글 정보 조회
|
||||
});
|
||||
|
||||
if (dailyViews.length === 0) {
|
||||
return NextResponse.json({ items: [], period });
|
||||
}
|
||||
|
||||
const postIds = dailyViews.map((dv) => dv.postId);
|
||||
|
||||
// 게시글 정보 조회
|
||||
const posts = await prisma.post.findMany({
|
||||
where: {
|
||||
id: { in: postIds },
|
||||
status: "published",
|
||||
...(boardId ? { boardId } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
boardId: true,
|
||||
board: { select: { id: true, name: true, slug: true } },
|
||||
isPinned: true,
|
||||
status: true,
|
||||
author: { select: { userId: true, nickname: true } },
|
||||
stat: { select: { recommendCount: true, views: true, commentsCount: true } },
|
||||
postTags: { select: { tag: { select: { name: true, slug: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
// 조회수와 게시글 매핑
|
||||
const viewCountMap = new Map(
|
||||
dailyViews.map((dv) => [dv.postId, dv._sum.viewCount ?? 0])
|
||||
);
|
||||
|
||||
// 조회수 순으로 정렬 (조회수가 0보다 큰 것만)
|
||||
const postsWithViews = posts
|
||||
.map((post) => ({
|
||||
...post,
|
||||
viewCount: viewCountMap.get(post.id) ?? 0,
|
||||
}))
|
||||
.filter((post) => post.viewCount > 0) // 조회수가 0보다 큰 것만
|
||||
.sort((a, b) => {
|
||||
// 고정글 우선
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
// 조회수 순
|
||||
if (b.viewCount !== a.viewCount) return b.viewCount - a.viewCount;
|
||||
// 최신순
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
})
|
||||
.slice(0, 5); // 상위 5개만
|
||||
|
||||
return NextResponse.json({ items: postsWithViews, period });
|
||||
}
|
||||
@@ -48,7 +48,7 @@ const listQuerySchema = z.object({
|
||||
pageSize: z.coerce.number().min(1).max(100).default(10),
|
||||
boardId: z.string().optional(),
|
||||
q: z.string().optional(),
|
||||
sort: z.enum(["recent", "popular"]).default("recent").optional(),
|
||||
sort: z.enum(["recent", "popular", "views", "likes", "comments"]).default("recent").optional(),
|
||||
tag: z.string().optional(), // Tag.slug
|
||||
author: z.string().optional(), // User.nickname contains
|
||||
authorId: z.string().optional(), // User.userId exact match
|
||||
@@ -103,8 +103,12 @@ export async function GET(req: Request) {
|
||||
prisma.post.findMany({
|
||||
where,
|
||||
orderBy:
|
||||
sort === "popular"
|
||||
sort === "popular" || sort === "likes"
|
||||
? [{ isPinned: "desc" }, { stat: { recommendCount: "desc" } }, { createdAt: "desc" }]
|
||||
: sort === "views"
|
||||
? [{ isPinned: "desc" }, { stat: { views: "desc" } }, { createdAt: "desc" }]
|
||||
: sort === "comments"
|
||||
? [{ isPinned: "desc" }, { stat: { commentsCount: "desc" } }, { createdAt: "desc" }]
|
||||
: [{ isPinned: "desc" }, { createdAt: "desc" }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
@@ -113,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 } } } },
|
||||
},
|
||||
|
||||
@@ -9,18 +9,27 @@ import { RankIcon1st } from "@/app/components/RankIcon1st";
|
||||
import { RankIcon2nd } from "@/app/components/RankIcon2nd";
|
||||
import { RankIcon3rd } from "@/app/components/RankIcon3rd";
|
||||
import { GradeIcon } from "@/app/components/GradeIcon";
|
||||
import AttendanceCalendar from "@/app/components/AttendanceCalendar";
|
||||
|
||||
// Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다.
|
||||
export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) {
|
||||
const p = params?.then ? await params : params;
|
||||
const sp = searchParams?.then ? await searchParams : searchParams;
|
||||
const idOrSlug = p.id as string;
|
||||
const sort = (sp?.sort as "recent" | "popular" | undefined) ?? "recent";
|
||||
const sort = (sp?.sort as "recent" | "popular" | "views" | "likes" | "comments" | undefined) ?? "recent";
|
||||
// 보드 slug 조회 (새 글 페이지 프리셋 전달)
|
||||
const h = await headers();
|
||||
const host = h.get("host") ?? "localhost:3000";
|
||||
const proto = h.get("x-forwarded-proto") ?? "http";
|
||||
const base = process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`;
|
||||
// 로그인 여부 파악
|
||||
const cookieHeader = h.get("cookie") || "";
|
||||
const uid = cookieHeader
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.find((pair) => pair.startsWith("uid="))
|
||||
?.split("=")[1];
|
||||
const isLoggedIn = !!uid;
|
||||
const res = await fetch(new URL("/api/boards", base).toString(), { cache: "no-store" });
|
||||
const { boards } = await res.json();
|
||||
const board = (boards || []).find((b: any) => b.slug === idOrSlug || b.id === idOrSlug);
|
||||
@@ -33,12 +42,13 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
||||
const parsed = settingRow ? JSON.parse(settingRow.value as string) : {};
|
||||
const showBanner: boolean = parsed.showBanner ?? true;
|
||||
|
||||
// 리스트 뷰 타입 확인 (특수랭킹일 경우 게시글 대신 랭킹 노출)
|
||||
// 리스트 뷰 타입 확인 (특수랭킹/출석부 등)
|
||||
const boardView = await prisma.board.findUnique({
|
||||
where: { id },
|
||||
select: { listViewType: { select: { key: true } } },
|
||||
});
|
||||
const isSpecialRanking = boardView?.listViewType?.key === "list_special_rank";
|
||||
const isAttendance = boardView?.listViewType?.key === "list_special_attendance";
|
||||
|
||||
let rankingItems: { userId: string; nickname: string; points: number; profileImage: string | null; grade: number }[] = [];
|
||||
if (isSpecialRanking) {
|
||||
@@ -70,8 +80,8 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
||||
href={`/boards/${b.slug}`}
|
||||
className={
|
||||
b.id === id
|
||||
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
|
||||
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:text-white border border-white/30 text-[12px] leading-[28px] whitespace-nowrap"
|
||||
? "px-3 h-[28px] mt-[11px] rounded-full bg-[#F94B37] text-white text-[12px] font-[700] leading-[28px] whitespace-nowrap"
|
||||
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:text-white border border-white/30 text-[12px] font-[700] leading-[28px] whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{b.name}
|
||||
@@ -83,8 +93,8 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
||||
)}
|
||||
|
||||
{/* 검색/필터 툴바 + 리스트 */}
|
||||
<section>
|
||||
{!isSpecialRanking && <BoardToolbar boardId={board?.slug} />}
|
||||
<section className="px-[0px] md:px-[30px] ">
|
||||
{!isSpecialRanking && !isAttendance && <BoardToolbar boardId={board?.slug} />}
|
||||
<div className="p-0">
|
||||
{isSpecialRanking ? (
|
||||
<div className="w-full">
|
||||
@@ -128,11 +138,16 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
||||
<div className="px-4 py-10 text-center text-neutral-500">랭킹 데이터가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
) : isAttendance ? (
|
||||
<div className="w-full py-4">
|
||||
<AttendanceCalendar isLoggedIn={isLoggedIn} />
|
||||
</div>
|
||||
) : (
|
||||
<PostList
|
||||
boardId={id}
|
||||
sort={sort}
|
||||
variant="board"
|
||||
titleHoverOrange
|
||||
newPostHref={`/posts/new?boardId=${id}${board?.slug ? `&boardSlug=${board.slug}` : ""}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
@@ -31,6 +31,11 @@ export function AppHeader() {
|
||||
const [indicatorLeft, setIndicatorLeft] = React.useState<number>(0);
|
||||
const [indicatorWidth, setIndicatorWidth] = React.useState<number>(0);
|
||||
const [indicatorVisible, setIndicatorVisible] = React.useState<boolean>(false);
|
||||
// 로그인 상태 확인 (전역 버튼 노출용)
|
||||
const { data: authData } = useSWR<{ user: { userId: string; nickname: string; profileImage: string | null; points: number; level: number; grade: number } | null }>(
|
||||
"/api/me",
|
||||
(u: string) => fetch(u).then((r) => r.json())
|
||||
);
|
||||
// 모바일 사이드바 열릴 때만 현재 사용자 정보를 가져옵니다(훅은 항상 동일한 순서로 호출)
|
||||
const { data: meData } = useSWR<{ user: { userId: string; nickname: string; profileImage: string | null; points: number; level: number; grade: number } | null }>(
|
||||
mobileOpen ? "/api/me" : null,
|
||||
@@ -343,7 +348,7 @@ export function AppHeader() {
|
||||
return (
|
||||
<header
|
||||
ref={headerRef}
|
||||
className={`relative flex items-center justify-between px-4 py-3 bg-white/80 backdrop-blur supports-[backdrop-filter]:bg-white/60 ${
|
||||
className={`relative flex items-center justify-between px-4 py-3 bg-white/90 backdrop-blur supports-[backdrop-filter]:bg-white/60 ${
|
||||
megaOpen ? "shadow-[0_6px_24px_rgba(0,0,0,0.10)]" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -423,7 +428,7 @@ export function AppHeader() {
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`fixed left-0 right-0 z-50 bg-white/80 backdrop-blur supports-[backdrop-filter]:bg-white/60 shadow-[0_12px_32px_rgba(0,0,0,0.12)] transition-all duration-200 ${
|
||||
className={`fixed left-0 right-0 z-50 bg-white/90 backdrop-blur supports-[backdrop-filter]:bg-white/60 shadow-[0_12px_32px_rgba(0,0,0,0.12)] transition-all duration-200 ${
|
||||
megaOpen ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
style={{ top: headerBottom }}
|
||||
@@ -466,15 +471,44 @@ export function AppHeader() {
|
||||
</div>
|
||||
</div>
|
||||
<div id="dummy" className="block"></div>
|
||||
<div className="hidden xl:flex xl:flex-1 justify-end">
|
||||
<div className="hidden xl:flex xl:flex-1 justify-end items-center gap-3">
|
||||
<SearchBar/>
|
||||
{authData?.user ? (
|
||||
<>
|
||||
{/* 인사 + 로그아웃을 하나의 배지로 묶어 자연스럽게 표시 */}
|
||||
<div className="inline-flex items-center h-10 rounded-md border border-neutral-300 bg-white overflow-hidden">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="ml-3 inline-flex items-center px-3 h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="어드민(임시)"
|
||||
href="/my-page"
|
||||
aria-label="마이페이지"
|
||||
className="h-full px-3 inline-flex items-center text-sm text-neutral-800 hover:bg-neutral-100 cursor-pointer truncate max-w-[220px]"
|
||||
>
|
||||
어드민(임시)
|
||||
{authData.user.nickname}님 안녕하세요
|
||||
</Link>
|
||||
<span aria-hidden className="w-px h-5 bg-neutral-200" />
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch("/api/auth/session", { method: "DELETE" });
|
||||
} finally {
|
||||
window.location.reload();
|
||||
}
|
||||
}}
|
||||
className="h-full px-3 text-sm text-neutral-700 hover:bg-neutral-100 focus:outline-none cursor-pointer"
|
||||
aria-label="로그아웃"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
href={`/login?next=${encodeURIComponent((pathname || "/") + (searchParams?.toString() ? `?${searchParams.toString()}` : ""))}`}
|
||||
className="inline-flex items-center px-3 h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="로그인"
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
<div
|
||||
@@ -505,36 +539,62 @@ export function AppHeader() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 animate-pulse">
|
||||
<div className="w-12 h-12 rounded-full bg-neutral-200" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="h-3 w-1/2 bg-neutral-200 rounded" />
|
||||
<div className="mt-2 h-3 w-1/3 bg-neutral-200 rounded" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-neutral-700">로그인이 필요합니다</div>
|
||||
<Link
|
||||
href={`/login?next=${encodeURIComponent((pathname || "/") + (searchParams?.toString() ? `?${searchParams.toString()}` : ""))}`}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="h-9 px-3 inline-flex items-center rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="로그인"
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{meData?.user && (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch("/api/auth/session", { method: "DELETE" });
|
||||
} finally {
|
||||
setMobileOpen(false);
|
||||
window.location.reload();
|
||||
}
|
||||
}}
|
||||
className="h-9 px-3 inline-flex items-center rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="로그아웃"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{meData?.user && (
|
||||
<div className="grid grid-cols-3 gap-2 mt-3">
|
||||
<Link href="/my-page?tab=points" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">포인트 히스토리</Link>
|
||||
<Link href="/my-page?tab=posts" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">내가 쓴 글</Link>
|
||||
<Link href="/my-page?tab=comments" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">내가 쓴 댓글</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SearchBar />
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="inline-flex items-center justify-center h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="어드민(임시)"
|
||||
>
|
||||
어드민(임시)
|
||||
</Link>
|
||||
<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>
|
||||
))}
|
||||
|
||||
246
src/app/components/AttendanceCalendar.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||
|
||||
function getMonthRange(date: Date) {
|
||||
const y = date.getFullYear();
|
||||
const m = date.getMonth(); // 0-based
|
||||
const first = new Date(y, m, 1);
|
||||
const last = new Date(y, m + 1, 0);
|
||||
return { y, m, first, last };
|
||||
}
|
||||
|
||||
export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?: boolean }) {
|
||||
const [current, setCurrent] = React.useState(new Date());
|
||||
const [days, setDays] = React.useState<string[]>([]);
|
||||
const [todayChecked, setTodayChecked] = React.useState<boolean | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [effectiveLoggedIn, setEffectiveLoggedIn] = React.useState<boolean>(!!isLoggedIn);
|
||||
const { show } = useToast();
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const { y, m } = getMonthRange(current);
|
||||
const ymKey = `${y}-${String(m + 1).padStart(2, "0")}`;
|
||||
const today = React.useMemo(() => {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
if (!isLoggedIn) {
|
||||
setDays([]);
|
||||
setTodayChecked(null);
|
||||
setEffectiveLoggedIn(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/attendance?year=${y}&month=${m + 1}`, { cache: "no-store" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!cancelled) {
|
||||
setDays(Array.isArray(data?.days) ? data.days : []);
|
||||
// today === null 이면 비로그인으로 판정
|
||||
const isAuthed = data?.today !== null && data?.today !== undefined;
|
||||
setEffectiveLoggedIn(isAuthed && !!isLoggedIn);
|
||||
setTodayChecked(isAuthed ? !!data?.today : null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [y, m, ymKey, isLoggedIn]);
|
||||
|
||||
const goPrev = () => setCurrent(new Date(current.getFullYear(), current.getMonth() - 1, 1));
|
||||
const goNext = () => setCurrent(new Date(current.getFullYear(), current.getMonth() + 1, 1));
|
||||
|
||||
const handleCheckIn = async () => {
|
||||
if (!effectiveLoggedIn) return;
|
||||
try {
|
||||
const res = await fetch("/api/attendance", { method: "POST" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok && !data?.duplicated) {
|
||||
const d = new Date();
|
||||
const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
setDays((prev) => (prev.includes(s) ? prev : [...prev, s]));
|
||||
setTodayChecked(true);
|
||||
show("출석 완료");
|
||||
} else if (res.ok) {
|
||||
setTodayChecked(true);
|
||||
show("출석 완료");
|
||||
}
|
||||
// 출석 통계/랭킹 리셋(재검증)
|
||||
mutate("/api/attendance/me-stats");
|
||||
mutate("/api/attendance/rankings");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// 캘린더 데이터 생성
|
||||
const firstOfMonth = new Date(y, m, 1);
|
||||
const startWeekday = firstOfMonth.getDay(); // 0:Sun
|
||||
const lastOfMonth = new Date(y, m + 1, 0).getDate();
|
||||
const weeks: Array<Array<{ d: number | null; dateStr?: string; checked?: boolean }>> = [];
|
||||
let day = 1;
|
||||
for (let w = 0; w < 6; w++) {
|
||||
const row: any[] = [];
|
||||
for (let wd = 0; wd < 7; wd++) {
|
||||
if (w === 0 && wd < startWeekday) {
|
||||
row.push({ d: null });
|
||||
} else if (day > lastOfMonth) {
|
||||
row.push({ d: null });
|
||||
} else {
|
||||
const dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
row.push({ d: day, dateStr, checked: days.includes(dateStr) });
|
||||
day++;
|
||||
}
|
||||
}
|
||||
weeks.push(row);
|
||||
if (day > lastOfMonth) break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[520px] mx-auto bg-white rounded-xl border border-neutral-200 p-4">
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<button onClick={goPrev} className="h-8 w-8 rounded-md border border-neutral-300 hover:bg-neutral-50">‹</button>
|
||||
<div className="text-sm font-semibold text-neutral-800">
|
||||
{y}년 {m + 1}월
|
||||
</div>
|
||||
<button onClick={goNext} className="h-8 w-8 rounded-md border border-neutral-300 hover:bg-neutral-50">›</button>
|
||||
</div>
|
||||
<table className="w-full text-center text-sm">
|
||||
<thead className="text-neutral-500">
|
||||
<tr>
|
||||
{["일","월","화","수","목","금","토"].map((h) => (<th key={h} className="py-1">{h}</th>))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{weeks.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{row.map((cell, j) => (
|
||||
<td key={`${i}-${j}`} className="h-9 align-middle">
|
||||
{cell.d ? (
|
||||
(() => {
|
||||
const isToday = cell.dateStr === today;
|
||||
const base = "mx-auto w-8 h-8 leading-8 rounded-full";
|
||||
const color = cell.checked ? " bg-[#F94B37] text-white" : " text-neutral-700";
|
||||
const ring = isToday ? (cell.checked ? " ring-2 ring-offset-2 ring-[#F94B37]" : " ring-2 ring-[#F94B37]") : "";
|
||||
return (
|
||||
<div className={`${base}${color}${ring}`}>
|
||||
{cell.d}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="w-7 h-7 mx-auto" />
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-2 flex items-center justify-center gap-3 text-[11px] text-neutral-600">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-3 rounded-full bg-[#F94B37]" />
|
||||
출석일
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-3 rounded-full ring-2 ring-[#F94B37]" />
|
||||
오늘
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-center">
|
||||
<button
|
||||
onClick={handleCheckIn}
|
||||
disabled={todayChecked === true || loading || !effectiveLoggedIn}
|
||||
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800 disabled:opacity-50"
|
||||
>
|
||||
{todayChecked ? "오늘 출석 완료" : "오늘 출석하기"}
|
||||
</button>
|
||||
</div>
|
||||
{/* 내 출석 통계 */}
|
||||
{effectiveLoggedIn && <MyAttendanceStats />}
|
||||
{!effectiveLoggedIn && (
|
||||
<div className="absolute inset-0 z-20 bg-white/70 backdrop-blur-[1px] rounded-lg flex items-center justify-center">
|
||||
<div className="text-sm font-semibold text-neutral-700">로그인이 필요합니다</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Rankings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MyAttendanceStats() {
|
||||
const { data } = useSWR<{ total: number; currentStreak: number; maxStreak: number }>(
|
||||
"/api/attendance/me-stats",
|
||||
(u) => fetch(u, { cache: "no-store" }).then((r) => r.json())
|
||||
);
|
||||
const total = data?.total ?? 0;
|
||||
const current = data?.currentStreak ?? 0;
|
||||
const max = data?.maxStreak ?? 0;
|
||||
return (
|
||||
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||
<div className="rounded-lg border border-neutral-200 px-3 py-2 text-center">
|
||||
<div className="text-[11px] text-neutral-600">내 출석일수</div>
|
||||
<div className="text-lg font-semibold text-neutral-900">{total}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-neutral-200 px-3 py-2 text-center">
|
||||
<div className="text-[11px] text-neutral-600">현재 연속출석</div>
|
||||
<div className="text-lg font-semibold text-neutral-900">{current}일</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-neutral-200 px-3 py-2 text-center">
|
||||
<div className="text-[11px] text-neutral-600">최대 연속</div>
|
||||
<div className="text-lg font-semibold text-neutral-900">{max}일</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Rankings() {
|
||||
const { data } = useSWR<{ overall?: any[]; streak: any[] }>(
|
||||
"/api/attendance/rankings",
|
||||
(u) => fetch(u, { cache: "no-store" }).then((r) => r.json())
|
||||
);
|
||||
const streak = data?.streak ?? [];
|
||||
const overall = data?.overall ?? [];
|
||||
return (
|
||||
<div className="mt-6 grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg border border-neutral-200">
|
||||
<div className="px-3 py-2 border-b border-neutral-200 text-sm font-semibold">최대 연속출석 순위</div>
|
||||
<ol className="p-3 space-y-2">
|
||||
{streak.length === 0 && <li className="text-xs text-neutral-500">데이터가 없습니다.</li>}
|
||||
{streak.map((u, idx) => (
|
||||
<li key={u.userId} className="flex items-center gap-3 text-sm">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-900 text-white text-xs">{idx + 1}</span>
|
||||
<span className="truncate">{u.nickname}</span>
|
||||
<span className="ml-auto text-xs text-neutral-600">{u.streak}일</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="rounded-lg border border-neutral-200">
|
||||
<div className="px-3 py-2 border-b border-neutral-200 text-sm font-semibold">출석일 순위</div>
|
||||
<ol className="p-3 space-y-2">
|
||||
{overall.length === 0 && <li className="text-xs text-neutral-500">데이터가 없습니다.</li>}
|
||||
{overall.map((u, idx) => (
|
||||
<li key={u.userId} className="flex items-center gap-3 text-sm">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-900 text-white text-xs">{idx + 1}</span>
|
||||
<span className="truncate">{u.nickname}</span>
|
||||
<span className="ml-auto text-xs text-neutral-600">{u.count}일</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { RankIcon1st } from "./RankIcon1st";
|
||||
import { RankIcon2nd } from "./RankIcon2nd";
|
||||
@@ -52,6 +52,23 @@ export function BoardPanelClient({
|
||||
}) {
|
||||
const [selectedBoardId, setSelectedBoardId] = useState(initialBoardId);
|
||||
|
||||
// 데이터가 비어있을 때 안전 처리
|
||||
if (!boardsData || boardsData.length === 0) {
|
||||
return (
|
||||
<div className="h-full min-h-0 flex items-center justify-center rounded-xl bg-white text-sm text-neutral-500">
|
||||
선택된 게시판이 없습니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 선택된 보드가 목록에 없으면 첫 번째 보드로 동기화
|
||||
useEffect(() => {
|
||||
const exists = boardsData.some((bd) => bd.board.id === selectedBoardId);
|
||||
if (!exists) {
|
||||
setSelectedBoardId(boardsData[0].board.id);
|
||||
}
|
||||
}, [boardsData, selectedBoardId]);
|
||||
|
||||
// 선택된 게시판 데이터 찾기
|
||||
const selectedBoardData = boardsData.find(bd => bd.board.id === selectedBoardId) || boardsData[0];
|
||||
const { board, categoryName, siblingBoards } = selectedBoardData;
|
||||
@@ -132,17 +149,19 @@ export function BoardPanelClient({
|
||||
{selectedBoardData.specialRankUsers.map((user, idx) => {
|
||||
const rank = idx + 1;
|
||||
return (
|
||||
<Link href="/boards/ranking" key={user.userId} className=" mx-[4px] flex h-[72px] items-center rounded-[10px] overflow-hidden bg-white hover:bg-neutral-50 transition-shadow hover:[box-shadow:0_0_2px_0_var(--color-alpha-shadow2,_rgba(0,0,0,0.08)),_0_var(--shadow-y-3,_8px)_var(--shadow-blur-3,_16px)_0_var(--color-alpha-shadow3,_rgba(0,0,0,0.12))]">
|
||||
<div className="h-[72px] w-[90px] relative shrink-0 bg-[#d5d5d5] overflow-hidden">
|
||||
<Link href="/boards/ranking" key={user.userId} className=" mx-[4px] flex h-[76px] items-center rounded-[10px] overflow-hidden bg-white hover:bg-neutral-50 transition-shadow hover:[box-shadow:0_0_2px_0_var(--color-alpha-shadow2,_rgba(0,0,0,0.08)),_0_var(--shadow-y-3,_8px)_var(--shadow-blur-3,_16px)_0_var(--color-alpha-shadow3,_rgba(0,0,0,0.12))] pl-[12px]">
|
||||
<div className="relative shrink-0">
|
||||
<div className="h-[56px] w-[56px] bg-[#d5d5d5] overflow-hidden rounded-full">
|
||||
<UserAvatar
|
||||
src={user.profileImage}
|
||||
alt={user.nickname || "프로필"}
|
||||
width={90}
|
||||
height={72}
|
||||
className="w-full h-full object-cover rounded-none"
|
||||
width={56}
|
||||
height={56}
|
||||
className="w-full h-full object-cover rounded-full"
|
||||
/>
|
||||
<div className="absolute top-0 right-0 w-[20px] h-[20px] flex items-center justify-center">
|
||||
<GradeIcon grade={user.grade} width={20} height={20} />
|
||||
</div>
|
||||
<div className="absolute -right-1 -bottom-1 w-[22px] h-[22px] flex items-center justify-center z-10">
|
||||
<GradeIcon grade={user.grade} width={22} height={22} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-[6px] px-[12px] md:px-[12px] py-[8px] min-w-0">
|
||||
@@ -315,7 +334,7 @@ export function BoardPanelClient({
|
||||
{selectedBoardData.textPosts.map((p) => (
|
||||
<li key={p.id} className="border-b border-[#ededed] h-[28px] pl-0 pr-[4px] pt-0 pb-0">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<Link href={`/posts/${p.id}`} className="group flex items-center gap-[4px] h-[32px] overflow-hidden flex-1 min-w-0">
|
||||
<Link href={`/posts/${p.id}`} className="group flex items-center gap-[4px] h-[24px] overflow-hidden flex-1 min-w-0 cursor-pointer">
|
||||
{isNewWithin1Hour(p.createdAt) && (
|
||||
<div className="relative w-[16px] h-[16px] shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React from "react";
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
|
||||
export function BoardToolbar({ boardId }: { boardId: string }) {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const sort = (sp.get("sort") as "recent" | "popular" | null) ?? "recent";
|
||||
const sort = (sp.get("sort") as "recent" | "popular" | "views" | "likes" | "comments" | null) ?? "recent";
|
||||
const scope = (sp.get("scope") as "q" | "author" | null) ?? "q"; // q: 제목+내용, author: 작성자
|
||||
const defaultText = scope === "author" ? sp.get("author") ?? "" : sp.get("q") ?? "";
|
||||
const period = sp.get("period") ?? "all"; // all | 1d | 1w | 1m
|
||||
@@ -16,6 +16,109 @@ export function BoardToolbar({ boardId }: { boardId: string }) {
|
||||
router.push(`/boards/${boardId}?${next.toString()}` , { scroll: false });
|
||||
};
|
||||
|
||||
const pushSort = (value: string) => {
|
||||
const next = new URLSearchParams(sp.toString());
|
||||
next.set("sort", value);
|
||||
router.push(`/boards/${boardId}?${next.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const SortDropdown = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const handler = (ev: MouseEvent) => { if (ref.current && !ref.current.contains(ev.target as Node)) setOpen(false); };
|
||||
window.addEventListener("click", handler);
|
||||
return () => window.removeEventListener("click", handler);
|
||||
}, []);
|
||||
const label =
|
||||
sort === "recent" ? "최신순" : sort === "views" ? "조회순" : sort === "likes" ? "좋아요순" : sort === "comments" ? "댓글순" : "인기순";
|
||||
const items: { value: string; text: string }[] = [
|
||||
{ value: "recent", text: "최신순" },
|
||||
{ value: "views", text: "조회순" },
|
||||
{ value: "likes", text: "좋아요순" },
|
||||
{ value: "comments", text: "댓글순" },
|
||||
];
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex h-12 px-4 items-center justify-center gap-1 rounded-[16px] border border-[#d5d5d5] bg-white text-[14px] text-[#161616] min-w-[93px] cursor-pointer hover:bg-neutral-100"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<path d="M5 8l5 5 5-5" stroke="#707070" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute mt-2 left-0 z-20 bg-white rounded-[16px] p-2 shadow-[0_0_2px_rgba(0,0,0,0.05),0_4px_8px_rgba(0,0,0,0.08)] w-[93px]">
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={it.value}
|
||||
type="button"
|
||||
onClick={() => { setOpen(false); pushSort(it.value); }}
|
||||
className={`w-full px-3 py-2 rounded-[8px] text-[14px] inline-flex items-center justify-center cursor-pointer ${
|
||||
sort === it.value ? "bg-[#707070] text-white" : "text-[#707070] hover:bg-neutral-100"
|
||||
}`}
|
||||
>
|
||||
{it.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ScopeDropdown = ({ value, onSelect }: { value: "q" | "author"; onSelect: (v: "q" | "author") => void }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const handler = (ev: MouseEvent) => { if (ref.current && !ref.current.contains(ev.target as Node)) setOpen(false); };
|
||||
window.addEventListener("click", handler);
|
||||
return () => window.removeEventListener("click", handler);
|
||||
}, []);
|
||||
const label = value === "author" ? "작성자" : "제목+내용";
|
||||
const items: { value: "q" | "author"; text: string }[] = [
|
||||
{ value: "q", text: "제목+내용" },
|
||||
{ value: "author", text: "작성자" },
|
||||
];
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex h-12 px-4 items-center justify-center gap-1 rounded-[16px] border border-[#d5d5d5] bg-white text-[14px] text-[#161616] min-w-[93px] cursor-pointer hover:bg-neutral-100"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<path d="M5 8l5 5 5-5" stroke="#707070" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute mt-2 left-0 z-20 bg-white rounded-[16px] p-2 shadow-[0_0_2px_rgba(0,0,0,0.05),0_4px_8px_rgba(0,0,0,0.08)] w-[120px]">
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={it.value}
|
||||
type="button"
|
||||
onClick={() => { onSelect(it.value); setOpen(false); }}
|
||||
className={`w-full px-3 py-2 rounded-[8px] text-[14px] inline-flex items-center justify-center cursor-pointer ${
|
||||
value === it.value ? "bg-[#707070] text-white" : "text-[#707070] hover:bg-neutral-100"
|
||||
}`}
|
||||
>
|
||||
{it.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const onChangePeriod = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const next = new URLSearchParams(sp.toString());
|
||||
const v = e.target.value;
|
||||
@@ -48,26 +151,67 @@ export function BoardToolbar({ boardId }: { boardId: string }) {
|
||||
<div className="px-0 py-2 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
{/* 검색바: 모바일에서는 상단 전체폭 */}
|
||||
<form action={onSubmit} className="order-1 md:order-2 flex items-center gap-2 w-full md:w-auto">
|
||||
<select name="scope" aria-label="검색대상" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm shrink-0" defaultValue={scope}>
|
||||
<option value="q">제목+내용</option>
|
||||
<option value="author">작성자</option>
|
||||
</select>
|
||||
<input name="text" defaultValue={defaultText} placeholder="검색어를 입력해 주세요." className="h-8 w-full md:w-72 px-3 rounded-md border border-neutral-300 text-sm placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-neutral-300" />
|
||||
<button type="submit" className="h-8 px-3 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800 shrink-0">검색</button>
|
||||
<input type="hidden" name="scope" value={scope} />
|
||||
<ScopeDropdown
|
||||
value={scope}
|
||||
onSelect={(v) => {
|
||||
// hidden input을 업데이트하기 위해 URL 파라미터는 변경하지 않고 폼 값만 유지
|
||||
// 검색 제출 시 onSubmit에서 반영됨
|
||||
const form = (document.activeElement as HTMLElement)?.closest("form") as HTMLFormElement | null;
|
||||
if (form) {
|
||||
const input = form.querySelector('input[name="scope"]') as HTMLInputElement | null;
|
||||
if (input) input.value = v;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="relative w-full md:w-96 group">
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="검색 실행"
|
||||
className="absolute right-2 top-2 w-8 h-8 text-neutral-500 cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
className="w-8 h-8 block group-hover:hidden group-focus-within:hidden"
|
||||
>
|
||||
<path
|
||||
d="M21 21L17.682 17.682M17.682 17.682C18.4963 16.8676 19 15.7426 19 14.5C19 12.0147 16.9853 10 14.5 10C12.0147 10 10 12.0147 10 14.5C10 16.9853 12.0147 19 14.5 19C15.7426 19 16.8676 18.4963 17.682 17.682ZM28 16C28 22.6274 22.6274 28 16 28C9.37258 28 4 22.6274 4 16C4 9.37258 9.37258 4 16 4C22.6274 4 28 9.37258 28 16Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
className="w-8 h-8 hidden group-hover:block group-focus-within:block"
|
||||
>
|
||||
<path d="M11 14.5C11 12.567 12.567 11 14.5 11C16.433 11 18 12.567 18 14.5C18 15.4668 17.6093 16.3404 16.9749 16.9749C16.3404 17.6093 15.4668 18 14.5 18C12.567 18 11 16.433 11 14.5Z" fill="#707070" />
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M16 3C8.8203 3 3 8.8203 3 16C3 23.1797 8.8203 29 16 29C23.1797 29 29 23.1797 29 16C29 8.8203 23.1797 3 16 3ZM14.5 9C11.4624 9 9 11.4624 9 14.5C9 17.5376 11.4624 20 14.5 20C15.6571 20 16.7316 19.6419 17.6174 19.0316L20.2929 21.7071C20.6834 22.0976 21.3166 22.0976 21.7071 21.7071C22.0976 21.3166 22.0976 20.6834 21.7071 20.2929L19.0316 17.6174C19.6419 16.7316 20 15.6571 20 14.5C20 11.4624 17.5376 9 14.5 9Z" fill="#707070" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
name="text"
|
||||
defaultValue={defaultText}
|
||||
placeholder="검색어를 입력해 주세요."
|
||||
className="w-full h-12 pr-12 pl-2 rounded-2xl border bg-white border-neutral-300 hover:border-[2px] hover:border-neutral-500 focus:border-2 focus:border-neutral-800 focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 필터: 모바일에서는 검색 아래쪽 */}
|
||||
<div className="order-2 md:order-1 flex items-center gap-2">
|
||||
<select aria-label="정렬" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={sort} onChange={onChangeSort}>
|
||||
<option value="recent">최신순</option>
|
||||
<option value="popular">인기순</option>
|
||||
</select>
|
||||
<select aria-label="기간" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={period} onChange={onChangePeriod}>
|
||||
<option value="all">전체기간</option>
|
||||
<option value="1d">24시간</option>
|
||||
<option value="1w">1주일</option>
|
||||
<option value="1m">1개월</option>
|
||||
</select>
|
||||
<SortDropdown />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,80 +28,21 @@ type Props = {
|
||||
postId: string;
|
||||
};
|
||||
|
||||
export function CommentSection({ postId }: Props) {
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
||||
const [newContent, setNewContent] = useState("");
|
||||
const [replyContents, setReplyContents] = useState<Record<string, string>>({});
|
||||
const [expandedReplies, setExpandedReplies] = useState<Set<string>>(new Set());
|
||||
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>>>;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadComments();
|
||||
}, [postId]);
|
||||
|
||||
async function loadComments() {
|
||||
try {
|
||||
const res = await fetch(`/api/posts/${postId}/comments`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setComments(data.comments || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitReply(parentId: string | null) {
|
||||
const content = parentId ? (replyContents[parentId] ?? "") : newContent;
|
||||
if (!content.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/comments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
postId,
|
||||
parentId,
|
||||
content,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
alert(error.error || "댓글 작성 실패");
|
||||
return;
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
setReplyContents((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[parentId!];
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setNewContent("");
|
||||
}
|
||||
setReplyingTo(null);
|
||||
loadComments();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("댓글 작성 실패");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleReplies(commentId: string) {
|
||||
const newExpanded = new Set(expandedReplies);
|
||||
if (newExpanded.has(commentId)) {
|
||||
newExpanded.delete(commentId);
|
||||
} else {
|
||||
newExpanded.add(commentId);
|
||||
}
|
||||
setExpandedReplies(newExpanded);
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
@@ -113,12 +55,34 @@ export function CommentSection({ postId }: Props) {
|
||||
if (hours < 24) return `${hours}시간 전`;
|
||||
if (days < 7) return `${days}일 전`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
function CommentItem({ comment, depth = 0 }: { comment: Comment; depth?: number }) {
|
||||
// 개별 댓글 아이템: 부모 렌더 시 타입이 바뀌어 리마운트되지 않도록 파일 최상위에 선언하고 memo 처리
|
||||
const CommentItem = memo(function CommentItem({
|
||||
comment,
|
||||
depth = 0,
|
||||
expandedReplies,
|
||||
toggleReplies,
|
||||
replyingTo,
|
||||
setReplyingTo,
|
||||
replyContents,
|
||||
setReplyContents,
|
||||
handleSubmitReply,
|
||||
replySecretFlags,
|
||||
setReplySecretFlags,
|
||||
}: CommentItemProps) {
|
||||
const canReply = depth < 2; // 최대 3단계까지만
|
||||
const 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" : ""}`}>
|
||||
@@ -133,7 +97,13 @@ export function CommentSection({ postId }: Props) {
|
||||
<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 || "익명"}
|
||||
{/* 닉네임 드롭다운 */}
|
||||
{/* 익명 댓글이면 드롭다운 없이 표시 */}
|
||||
{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 && (
|
||||
@@ -166,16 +136,28 @@ export function CommentSection({ postId }: Props) {
|
||||
</div>
|
||||
|
||||
{/* 답글 입력 폼 */}
|
||||
{replyingTo === comment.id && (
|
||||
{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 gap-2 mt-2 justify-end">
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<label className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!replySecretFlags[comment.id]}
|
||||
onChange={(e) =>
|
||||
setReplySecretFlags((prev) => ({ ...prev, [comment.id]: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
비밀 댓글
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setReplyingTo(null);
|
||||
@@ -184,6 +166,11 @@ export function CommentSection({ postId }: Props) {
|
||||
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"
|
||||
>
|
||||
@@ -197,6 +184,7 @@ export function CommentSection({ postId }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,12 +193,107 @@ export function CommentSection({ postId }: Props) {
|
||||
{hasReplies && isExpanded && (
|
||||
<div className="mt-2">
|
||||
{comment.replies.map((reply) => (
|
||||
<CommentItem key={reply.id} comment={reply} depth={depth + 1} />
|
||||
<CommentItem
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
depth={depth + 1}
|
||||
expandedReplies={expandedReplies}
|
||||
toggleReplies={toggleReplies}
|
||||
replyingTo={replyingTo}
|
||||
setReplyingTo={setReplyingTo}
|
||||
replyContents={replyContents}
|
||||
setReplyContents={setReplyContents}
|
||||
handleSubmitReply={handleSubmitReply}
|
||||
replySecretFlags={replySecretFlags}
|
||||
setReplySecretFlags={setReplySecretFlags}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export function CommentSection({ postId }: Props) {
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
||||
const [newContent, setNewContent] = useState("");
|
||||
const [replyContents, setReplyContents] = useState<Record<string, string>>({});
|
||||
const [expandedReplies, setExpandedReplies] = useState<Set<string>>(new Set());
|
||||
const [newIsSecret, setNewIsSecret] = useState(false);
|
||||
const [replySecretFlags, setReplySecretFlags] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadComments();
|
||||
}, [postId]);
|
||||
|
||||
async function loadComments() {
|
||||
try {
|
||||
const res = await fetch(`/api/posts/${postId}/comments`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setComments(data.comments || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitReply(parentId: string | null) {
|
||||
const content = parentId ? (replyContents[parentId] ?? "") : newContent;
|
||||
if (!content.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/comments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
postId,
|
||||
parentId,
|
||||
content,
|
||||
isSecret: parentId ? !!replySecretFlags[parentId] : newIsSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
alert(error.error || "댓글 작성 실패");
|
||||
return;
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
setReplyContents((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[parentId!];
|
||||
return next;
|
||||
});
|
||||
setReplySecretFlags((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[parentId!];
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setNewContent("");
|
||||
setNewIsSecret(false);
|
||||
}
|
||||
setReplyingTo(null);
|
||||
loadComments();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("댓글 작성 실패");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleReplies(commentId: string) {
|
||||
const newExpanded = new Set(expandedReplies);
|
||||
if (newExpanded.has(commentId)) {
|
||||
newExpanded.delete(commentId);
|
||||
} else {
|
||||
newExpanded.add(commentId);
|
||||
}
|
||||
setExpandedReplies(newExpanded);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@@ -237,7 +320,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 +345,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>
|
||||
)}
|
||||
|
||||
@@ -235,10 +235,6 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "H2")} aria-label="H2">H2</button>
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "H3")} aria-label="H3">H3</button>
|
||||
<span className="mx-2 h-4 w-px bg-neutral-300" />
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertOrderedList")} aria-label="번호 목록">1.</button>
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertUnorderedList")} aria-label="글머리 목록">•</button>
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => wrapSelectionWithHtml("<code>", "</code>")} aria-label="코드"></></button>
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "BLOCKQUOTE")} aria-label="인용구">❝</button>
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertHorizontalRule")} aria-label="구분선">—</button>
|
||||
<span className="mx-2 h-4 w-px bg-neutral-300" />
|
||||
<button
|
||||
@@ -293,13 +289,6 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
|
||||
<span className="block h-0.5 bg-current" style={{ width: "100%" }} />
|
||||
</span>
|
||||
</button>
|
||||
<span className="mx-2 h-4 w-px bg-neutral-300" />
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("indent")} aria-label="들여쓰기">⇥</button>
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("outdent")} aria-label="내어쓰기">⇤</button>
|
||||
<span className="mx-2 h-4 w-px bg-neutral-300" />
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("removeFormat")} aria-label="서식 제거">clear</button>
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("undo")} aria-label="되돌리기">↶</button>
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("redo")} aria-label="다시하기">↷</button>
|
||||
</div>
|
||||
);
|
||||
}, [exec, withToolbar, wrapSelectionWithHtml]);
|
||||
@@ -318,7 +307,7 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
data-placeholder={placeholder}
|
||||
style={{
|
||||
minHeight: 160,
|
||||
minHeight: 500,
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
|
||||
@@ -10,7 +10,38 @@ type SubItem = { id: string; name: string; href: string };
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export function HeroBanner({ subItems, activeSubId, hideSubOnMobile }: { subItems?: SubItem[]; activeSubId?: string; hideSubOnMobile?: boolean }) {
|
||||
export function HeroBanner({ subItems, activeSubId, hideSubOnMobile, showPartnerCats }: { subItems?: SubItem[]; activeSubId?: string; hideSubOnMobile?: boolean; showPartnerCats?: boolean }) {
|
||||
const usePartnerCats = ((!Array.isArray(subItems) || subItems.length === 0) && showPartnerCats !== false);
|
||||
// 파트너 카테고리 불러오기 (홈 배너 하단 블랙바에 표시)
|
||||
const { data: catData } = useSWR<{ categories: any[] }>(usePartnerCats ? "/api/partner-categories" : null, fetcher, { revalidateOnFocus: false, dedupingInterval: 5 * 60 * 1000 });
|
||||
const categories = catData?.categories ?? [];
|
||||
const [selectedCatId, setSelectedCatId] = useState<string>("");
|
||||
useEffect(() => {
|
||||
if (!usePartnerCats) return;
|
||||
if (!selectedCatId && categories.length > 0) {
|
||||
let id = "";
|
||||
try {
|
||||
id = (window as any).__partnerCategoryId || localStorage.getItem("selectedPartnerCategoryId") || "";
|
||||
} catch {}
|
||||
if (!id) id = categories[0].id;
|
||||
setSelectedCatId(id);
|
||||
try {
|
||||
(window as any).__partnerCategoryId = id;
|
||||
localStorage.setItem("selectedPartnerCategoryId", id);
|
||||
window.dispatchEvent(new CustomEvent("partnerCategorySelect", { detail: { id } }));
|
||||
} catch {}
|
||||
}
|
||||
}, [usePartnerCats, categories, selectedCatId]);
|
||||
const onSelectCategory = useCallback((id: string) => {
|
||||
if (!usePartnerCats) return;
|
||||
setSelectedCatId(id);
|
||||
// 전역 이벤트로 선택 전달
|
||||
try {
|
||||
(window as any).__partnerCategoryId = id;
|
||||
localStorage.setItem("selectedPartnerCategoryId", id);
|
||||
window.dispatchEvent(new CustomEvent("partnerCategorySelect", { detail: { id } }));
|
||||
} catch {}
|
||||
}, [usePartnerCats]);
|
||||
// SWR 캐시 사용: 페이지 전환 시 재요청/깜빡임 최소화
|
||||
const { data } = useSWR<{ banners: Banner[] }>("/api/banners", fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
@@ -78,23 +109,46 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile }: { subItem
|
||||
<section className="relative w-full overflow-hidden rounded-xl bg-neutral-900 text-white" aria-roledescription="carousel">
|
||||
<SelectedBanner className="h-56 sm:h-72 md:h-[264px]" />
|
||||
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 (스켈레톤 상태에서도 동일 레이아웃 유지) */}
|
||||
<div className={`${hideSubOnMobile ? "hidden md:flex" : "flex"} h-[58px] bg-black rounded-xl items-center justify-center px-2`}>
|
||||
{Array.isArray(subItems) && subItems.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-[8px]">
|
||||
<div className={`${hideSubOnMobile ? "hidden md:flex" : "flex"} h-[58px] bg-black rounded-xl items-end justify-center px-2`}>
|
||||
{usePartnerCats ? (
|
||||
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||
{categories.map((c: any) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => onSelectCategory(c.id)}
|
||||
className={
|
||||
(selectedCatId || categories[0]?.id) === c.id
|
||||
? "px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap"
|
||||
: "px-3 h-full inline-flex items-center bg-transparent text-[#d5d5d5] hover:bg-white hover:text-[#d73b29] hover:font-[700] hover:rounded-tl-[14px] hover:rounded-tr-[14px] text-[20px] font-[500] whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
Array.isArray(subItems) && subItems.length > 0 && (
|
||||
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||
{subItems.map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={s.href}
|
||||
className={
|
||||
s.id === activeSubId
|
||||
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
|
||||
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:bg-[#F94B37] hover:text-white text-[12px] leading-[28px] whitespace-nowrap"
|
||||
? "px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap"
|
||||
: "px-3 h-full inline-flex items-center bg-transparent text-[#d5d5d5] hover:bg-white hover:text-[#d73b29] hover:font-[700] hover:rounded-tl-[14px] hover:rounded-tr-[14px] text-[20px] font-[500] whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && (
|
||||
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||
<span className="px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap">암실소문</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -177,23 +231,46 @@ export function HeroBanner({ subItems, activeSubId, hideSubOnMobile }: { subItem
|
||||
</div>
|
||||
|
||||
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 */}
|
||||
<div className={`${hideSubOnMobile ? "hidden md:flex" : "flex"} h-[58px] bg-black rounded-xl items-center justify-center px-2`}>
|
||||
{Array.isArray(subItems) && subItems.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-[8px]">
|
||||
<div className={`${hideSubOnMobile ? "hidden md:flex" : "flex"} h-[58px] bg-black rounded-xl items-end justify-center px-2`}>
|
||||
{usePartnerCats ? (
|
||||
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||
{categories.map((c: any) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => onSelectCategory(c.id)}
|
||||
className={
|
||||
(selectedCatId || categories[0]?.id) === c.id
|
||||
? "px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap"
|
||||
: "px-3 h-full inline-flex items-center bg-transparent text-[#d5d5d5] hover:bg-white hover:text-[#d73b29] hover:font-[700] hover:rounded-tl-[14px] hover:rounded-tr-[14px] text-[20px] font-[500] whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
Array.isArray(subItems) && subItems.length > 0 && (
|
||||
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||
{subItems.map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={s.href}
|
||||
className={
|
||||
s.id === activeSubId
|
||||
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
|
||||
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:bg-[#F94B37] hover:text-white text-[12px] leading-[28px] whitespace-nowrap"
|
||||
? "px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap"
|
||||
: "px-3 h-full inline-flex items-center bg-transparent text-[#d5d5d5] hover:bg-white hover:text-[#d73b29] hover:font-[700] hover:rounded-tl-[14px] hover:rounded-tr-[14px] text-[20px] font-[500] whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && (
|
||||
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||
<span className="px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap">암실소문</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ export default function HorizontalCardScroller({ items }: HorizontalCardScroller
|
||||
const CARD_WIDTH = 384;
|
||||
const CARD_GAP = 16; // Tailwind gap-4
|
||||
const SCROLL_STEP = CARD_WIDTH + CARD_GAP;
|
||||
useEffect(() => {
|
||||
console.log(items);
|
||||
}, [items]);
|
||||
|
||||
const updateThumb = useCallback(() => {
|
||||
const scroller = scrollRef.current;
|
||||
@@ -103,11 +106,7 @@ export default function HorizontalCardScroller({ items }: HorizontalCardScroller
|
||||
{items.map((card) => (
|
||||
<article
|
||||
key={card.id}
|
||||
className="flex-shrink-0 w-[384px] h-[308px] rounded-[16px] bg-white overflow-hidden"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 1px 2px 0 var(--color-alpha-shadow1, rgba(0, 0, 0, 0.05)), 0 0 2px 0 var(--color-alpha-shadow1, rgba(0, 0, 0, 0.05))",
|
||||
}}
|
||||
className="flex-shrink-0 w-[384px] h-[308px] rounded-[16px] bg-white overflow-hidden shadow-[0_1px_2px_rgba(0,0,0,0.05),0_0_2px_rgba(0,0,0,0.05)] transition-shadow duration-200 hover:shadow-[0_0_2px_rgba(0,0,0,0.08),0_8px_16px_rgba(0,0,0,0.12)]"
|
||||
>
|
||||
<div className="grid grid-rows-[192px_116px] h-full">
|
||||
{/* 상단: 사진 384x192, 상단 라운드 16, 하단 0 */}
|
||||
@@ -149,20 +148,20 @@ export default function HorizontalCardScroller({ items }: HorizontalCardScroller
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute bottom-[20px] left-1/2 -translate-x-1/2 z-20 flex items-center gap-3">
|
||||
<div className="pointer-events-none absolute bottom-[-5px] left-1/2 -translate-x-1/2 z-20 flex items-center justify-center gap-6 w-full">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="이전"
|
||||
className="pointer-events-auto p-0 m-0 bg-transparent text-orange-500 hover:text-orange-600 focus:outline-none"
|
||||
className="pointer-events-auto p-0 m-0 bg-transparent text-neutral-500 hover:text-neutral-700 focus:outline-none size-[30px] inline-flex items-center justify-center"
|
||||
onClick={() => scrollByStep(-1)}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
|
||||
<polygon points="10,0 0,6 10,12" />
|
||||
<svg width="30" height="30" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="rotate-180">
|
||||
<path d="M9.5 5.5L15 12l-5.5 6.5" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div ref={trackRef} className="pointer-events-auto relative h-2 w-[30vw] rounded-full bg-orange-200/50">
|
||||
<div ref={trackRef} className="pointer-events-auto relative h-1 flex-1 min-w-0 max-w-[480px] rounded bg-[#EDEDED]">
|
||||
<div
|
||||
className="absolute top-0 h-2 rounded-full bg-orange-500"
|
||||
className="absolute top-0 h-1 rounded bg-[var(--red-50,#F94B37)]"
|
||||
style={{ width: `${thumbWidth}px`, left: `${thumbLeft}px` }}
|
||||
onMouseDown={handleThumbMouseDown}
|
||||
/>
|
||||
@@ -170,11 +169,11 @@ export default function HorizontalCardScroller({ items }: HorizontalCardScroller
|
||||
<button
|
||||
type="button"
|
||||
aria-label="다음"
|
||||
className="pointer-events-auto p-0 m-0 bg-transparent text-orange-500 hover:text-orange-600 focus:outline-none"
|
||||
className="pointer-events-auto p-0 m-0 bg-transparent text-neutral-500 hover:text-neutral-700 focus:outline-none size-[30px] inline-flex items-center justify-center"
|
||||
onClick={() => scrollByStep(1)}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
|
||||
<polygon points="0,0 10,6 0,12" />
|
||||
<svg width="30" height="30" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M9.5 5.5L15 12l-5.5 6.5" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
69
src/app/components/InlineLoginForm.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function InlineLoginForm({ next = "/" }: { next?: string }) {
|
||||
const [nickname, setNickname] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="w-[300px] flex flex-col gap-2">
|
||||
<input
|
||||
placeholder="아이디"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
className="h-10 rounded-md border border-neutral-300 px-3 text-sm"
|
||||
/>
|
||||
<input
|
||||
placeholder="비밀번호"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="h-10 rounded-md border border-neutral-300 px-3 text-sm"
|
||||
/>
|
||||
{error && <div className="text-xs text-red-600 mt-1">{error}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full h-[40px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[14px] font-[700] flex items-center justify-center disabled:opacity-60"
|
||||
>
|
||||
{loading ? "로그인 중..." : "로그인"}
|
||||
</button>
|
||||
<div className="flex justify-end">
|
||||
<Link href="/register" className="text-[13px] text-[#5c5c5c] hover:underline">회원가입</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
61
src/app/components/PartnerCategorySection.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
import useSWR from "swr";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export default function PartnerCategorySection() {
|
||||
const { data: catData } = useSWR<{ categories: any[] }>("/api/partner-categories", fetcher);
|
||||
const categories = catData?.categories ?? [];
|
||||
const defaultCatId = categories[0]?.id || "";
|
||||
const [selectedId, setSelectedId] = useState<string>(defaultCatId);
|
||||
|
||||
const query = useMemo(() => {
|
||||
const id = selectedId || defaultCatId;
|
||||
return id ? `/api/partners?categoryId=${encodeURIComponent(id)}` : "/api/partners";
|
||||
}, [selectedId, defaultCatId]);
|
||||
|
||||
const { data: partnersData, isLoading } = useSWR<{ partners: any[] }>(query, fetcher);
|
||||
const partners = partnersData?.partners ?? [];
|
||||
|
||||
return (
|
||||
<section>
|
||||
{/* 카테고리 탭 */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{categories.map((c: any) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setSelectedId(c.id)}
|
||||
className={`px-3 h-8 rounded-full border text-sm whitespace-nowrap ${ (selectedId || defaultCatId) === c.id ? "bg-neutral-900 text-white border-neutral-900" : "border-neutral-300 hover:bg-neutral-100"}`}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 파트너 리스트 */}
|
||||
<div className="mt-4 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{isLoading && partners.length === 0 && (
|
||||
<div className="col-span-full text-sm text-neutral-500">불러오는 중...</div>
|
||||
)}
|
||||
{partners.map((p: any) => (
|
||||
<div key={p.id} className="rounded-lg border border-neutral-200 overflow-hidden bg-white">
|
||||
<div className="w-full aspect-[4/3] bg-neutral-100">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={p.imageUrl || "/sample.jpg"} alt={p.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<div className="text-sm font-semibold truncate">{p.name}</div>
|
||||
<div className="text-xs text-neutral-500 truncate">{p.address || ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!isLoading && partners.length === 0 && (
|
||||
<div className="col-span-full text-sm text-neutral-500">표시할 제휴업체가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
99
src/app/components/PartnerScroller.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
import useSWR from "swr";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import HorizontalCardScroller from "@/app/components/HorizontalCardScroller";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export default function PartnerScroller() {
|
||||
const { data: catData } = useSWR<{ categories: any[] }>("/api/partner-categories", fetcher);
|
||||
const categories = catData?.categories ?? [];
|
||||
const defaultCatId = categories[0]?.id || "";
|
||||
const [selectedId, setSelectedId] = useState<string>(defaultCatId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
let id = "";
|
||||
try {
|
||||
id = (window as any).__partnerCategoryId || localStorage.getItem("selectedPartnerCategoryId") || "";
|
||||
} catch {}
|
||||
if (!id) id = defaultCatId;
|
||||
if (id) setSelectedId(id);
|
||||
}
|
||||
}, [defaultCatId, selectedId]);
|
||||
|
||||
// Listen to HeroBanner selection events
|
||||
useEffect(() => {
|
||||
// initialize from global if present
|
||||
try {
|
||||
const initial = (window as any).__partnerCategoryId;
|
||||
const stored = localStorage.getItem("selectedPartnerCategoryId");
|
||||
if (initial) setSelectedId(initial);
|
||||
else if (stored) setSelectedId(stored);
|
||||
} catch {}
|
||||
const handler = (e: Event) => {
|
||||
const ce = e as CustomEvent<{ id: string }>;
|
||||
if (ce?.detail?.id) setSelectedId(ce.detail.id);
|
||||
};
|
||||
window.addEventListener("partnerCategorySelect", handler as EventListener);
|
||||
return () => window.removeEventListener("partnerCategorySelect", handler as EventListener);
|
||||
}, []);
|
||||
|
||||
const partnersQuery = useMemo(() => (selectedId ? `/api/partners?categoryId=${encodeURIComponent(selectedId)}` : "/api/partners"), [selectedId]);
|
||||
const { data: partnersData } = useSWR<{ partners: any[] }>(partnersQuery, fetcher);
|
||||
const partners = partnersData?.partners ?? [];
|
||||
|
||||
// Fallback to approved partner requests if no partners
|
||||
const { data: reqData } = useSWR<{ items: any[] }>(partners.length === 0 ? "/api/partner-shops" : null, fetcher);
|
||||
const activeCategoryName = useMemo(() => categories.find((c: any) => c.id === selectedId)?.name, [categories, selectedId]);
|
||||
const fallbackItems = useMemo(() => {
|
||||
if (!reqData?.items) return [] as any[];
|
||||
const filtered = activeCategoryName ? reqData.items.filter((it: any) => it.category === activeCategoryName) : reqData.items;
|
||||
return filtered.map((s: any) => ({ id: s.id, region: s.region, name: s.name, address: s.address, image: s.imageUrl || "/sample.jpg" }));
|
||||
}, [reqData, activeCategoryName]);
|
||||
|
||||
const items = partners.length > 0
|
||||
? partners.map((p: any) => ({ id: p.id, region: p.address ? String(p.address).split(" ")[0] : p.category, name: p.name, address: p.address || "", image: p.imageUrl || "/sample.jpg" }))
|
||||
: fallbackItems;
|
||||
|
||||
const isLoading = !partnersData && (!reqData || partners.length === 0);
|
||||
|
||||
if (isLoading) {
|
||||
// 스켈레톤: 실제 카드와 동일 사이즈(384x308)로 5~6개 표시
|
||||
const skeletons = Array.from({ length: 6 }).map((_, i) => i);
|
||||
return (
|
||||
<div className="relative h-[400px]">
|
||||
<div className="scrollbar-hidden h-full overflow-x-auto overflow-y-hidden">
|
||||
<div className="flex h-full items-center gap-4">
|
||||
{skeletons.map((i) => (
|
||||
<article
|
||||
key={`sk-${i}`}
|
||||
className="flex-shrink-0 w-[384px] h-[308px] rounded-[16px] bg-white overflow-hidden shadow-[0_1px_2px_rgba(0,0,0,0.05),0_0_2px_rgba(0,0,0,0.05)]"
|
||||
>
|
||||
<div className="grid grid-rows-[192px_116px] h-full">
|
||||
<div className="w-full h-[192px] overflow-hidden rounded-t-[16px] bg-neutral-200 animate-pulse" />
|
||||
<div className="h-[116px] px-8 py-4 grid grid-rows-[26px_auto_16px]">
|
||||
<div className="w-[68px] h-[26px] rounded-[20px] bg-neutral-200 animate-pulse" />
|
||||
<div className="self-center">
|
||||
<div className="h-6 w-[70%] rounded bg-neutral-200 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-4 w-[60%] rounded bg-neutral-200 animate-pulse" />
|
||||
<div className="h-4 w-8 rounded bg-neutral-200 animate-pulse" />
|
||||
<div className="h-4 w-8 rounded bg-neutral-200 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) return null;
|
||||
return <HorizontalCardScroller items={items} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ import useSWRInfinite from "swr/infinite";
|
||||
import useSWR from "swr";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import ViewsIcon from "@/app/svgs/ViewsIcon";
|
||||
import LikeIcon from "@/app/svgs/LikeIcon";
|
||||
import CommentIcon from "@/app/svgs/CommentIcon";
|
||||
import PlusIcon from "@/app/svgs/PlusIcon";
|
||||
|
||||
type Item = {
|
||||
id: string;
|
||||
@@ -14,9 +15,11 @@ 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;
|
||||
viewCount?: number; // 일일/주간 조회수
|
||||
};
|
||||
|
||||
type Resp = {
|
||||
@@ -24,7 +27,7 @@ type Resp = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
items: Item[];
|
||||
sort: "recent" | "popular";
|
||||
sort: "recent" | "popular" | "views" | "likes" | "comments";
|
||||
};
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
@@ -35,11 +38,20 @@ function stripHtml(html: string | null | undefined): string {
|
||||
return html.replace(/<[^>]*>/g, "").trim();
|
||||
}
|
||||
|
||||
export function PostList({ boardId, sort = "recent", q, tag, author, authorId, start, end, variant = "default", newPostHref, titleHoverOrange, pageSizeOverride, compact }: { boardId?: string; sort?: "recent" | "popular"; q?: string; tag?: string; author?: string; authorId?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string; titleHoverOrange?: boolean; pageSizeOverride?: number; compact?: boolean }) {
|
||||
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 router = useRouter();
|
||||
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [lockedMinHeight, setLockedMinHeight] = useState<number | null>(null);
|
||||
|
||||
// 인기글 데이터 가져오기 (board variant일 때만)
|
||||
const popularDailyKey = variant === "board" ? `/api/posts/popular?${boardId ? `boardId=${boardId}&` : ""}period=daily` : null;
|
||||
const popularWeeklyKey = variant === "board" ? `/api/posts/popular?${boardId ? `boardId=${boardId}&` : ""}period=weekly` : null;
|
||||
const { data: dailyData } = useSWR<{ items: Item[] }>(popularDailyKey, fetcher);
|
||||
const { data: weeklyData } = useSWR<{ items: Item[] }>(popularWeeklyKey, fetcher);
|
||||
|
||||
// board 변형에서는 URL에서 pageSize를 읽고, 기본값은 20
|
||||
const defaultPageSize = variant === "board" ? 20 : 10;
|
||||
const pageSizeParam = sp.get("pageSize");
|
||||
@@ -123,8 +135,139 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
|
||||
|
||||
const initials = (name?: string | null) => (name ? name.trim().slice(0, 1) : "익");
|
||||
|
||||
// 표시 개수 드롭다운 (BoardToolbar 드롭다운과 동일한 디자인/호버)
|
||||
const PageSizeDropdown = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const handler = (ev: MouseEvent) => { if (ref.current && !ref.current.contains(ev.target as Node)) setOpen(false); };
|
||||
window.addEventListener("click", handler);
|
||||
return () => window.removeEventListener("click", handler);
|
||||
}, []);
|
||||
const sizes = [10, 20, 30, 40, 50];
|
||||
const onSelectSize = (newSize: number) => {
|
||||
setCurrentPageSize(newSize);
|
||||
setPage(1);
|
||||
const nextSp = new URLSearchParams(Array.from(sp.entries()));
|
||||
nextSp.set("pageSize", String(newSize));
|
||||
nextSp.set("page", "1");
|
||||
if (typeof window !== "undefined") {
|
||||
window.history.replaceState(null, "", `?${nextSp.toString()}`);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex h-12 px-4 items-center justify-center gap-1 rounded-[16px] border border-[#d5d5d5] bg-white text-[14px] text-[#161616] min-w-[93px] cursor-pointer hover:bg-neutral-100"
|
||||
>
|
||||
<span>{currentPageSize}개</span>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<path d="M5 8l5 5 5-5" stroke="#707070" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute mt-2 left-0 z-20 bg-white rounded-[16px] p-2 shadow-[0_0_2px_rgba(0,0,0,0.05),0_4px_8px_rgba(0,0,0,0.08)] w-[120px]">
|
||||
<div className="flex flex-col gap-1">
|
||||
{sizes.map((sz) => (
|
||||
<button
|
||||
key={sz}
|
||||
type="button"
|
||||
onClick={() => { setOpen(false); onSelectSize(sz); }}
|
||||
className={`w-full px-3 py-2 rounded-[8px] text-[14px] inline-flex items-center justify-center cursor-pointer ${
|
||||
currentPageSize === sz ? "bg-[#707070] text-white" : "text-[#707070] hover:bg-neutral-100"
|
||||
}`}
|
||||
>
|
||||
{sz}개
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* 인기글 섹션 (board variant일 때만 표시) */}
|
||||
{variant === "board" && (dailyData?.items.length || weeklyData?.items.length) && (
|
||||
<div className="mb-6 space-y-4">
|
||||
{/* 일간 인기글 */}
|
||||
{dailyData?.items && dailyData.items.length > 0 && (
|
||||
<div className="border border-[#e6e6e6] rounded-xl overflow-hidden">
|
||||
<div className="bg-[#f6f4f4] px-4 py-2 border-b border-[#e6e6e6]">
|
||||
<h3 className="text-[14px] font-semibold text-[#161616]">🔥 일간 인기글</h3>
|
||||
</div>
|
||||
<ul className="divide-y divide-[#e6e6e6]">
|
||||
{dailyData.items.map((p) => (
|
||||
<li key={p.id} className="px-4 py-3 hover:bg-neutral-50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className="group block truncate text-neutral-900 cursor-pointer"
|
||||
onClick={() => router.push(`/posts/${p.id}`)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/posts/${p.id}`); }}
|
||||
>
|
||||
<span className="text-[15px] group-hover:text-[var(--red-50,#F94B37)] group-hover:font-[700]">
|
||||
{stripHtml(p.title)}
|
||||
</span>
|
||||
{(p.stat?.commentsCount ?? 0) > 0 && (
|
||||
<span className="ml-1 text-[12px] text-[#f94b37] align-middle">[{p.stat?.commentsCount}]</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-[#8c8c8c] shrink-0">
|
||||
<span className="inline-flex items-center gap-1"><ViewsIcon width={14} height={14} />{p.viewCount ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 주간 인기글 */}
|
||||
{weeklyData?.items && weeklyData.items.length > 0 && (
|
||||
<div className="border border-[#e6e6e6] rounded-xl overflow-hidden">
|
||||
<div className="bg-[#f6f4f4] px-4 py-2 border-b border-[#e6e6e6]">
|
||||
<h3 className="text-[14px] font-semibold text-[#161616]">⭐ 주간 인기글</h3>
|
||||
</div>
|
||||
<ul className="divide-y divide-[#e6e6e6]">
|
||||
{weeklyData.items.map((p) => (
|
||||
<li key={p.id} className="px-4 py-3 hover:bg-neutral-50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className="group block truncate text-neutral-900 cursor-pointer"
|
||||
onClick={() => router.push(`/posts/${p.id}`)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/posts/${p.id}`); }}
|
||||
>
|
||||
<span className="text-[15px] group-hover:text-[var(--red-50,#F94B37)] group-hover:font-[700]">
|
||||
{stripHtml(p.title)}
|
||||
</span>
|
||||
{(p.stat?.commentsCount ?? 0) > 0 && (
|
||||
<span className="ml-1 text-[12px] text-[#f94b37] align-middle">[{p.stat?.commentsCount}]</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-[#8c8c8c] shrink-0">
|
||||
<span className="inline-flex items-center gap-1"><ViewsIcon width={14} height={14} />{p.viewCount ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 정렬 스위치 (board 변형에서는 상단 툴바를 숨김) */}
|
||||
{variant !== "board" && (
|
||||
<div className="px-4 py-2 border-b border-neutral-200 mb-2 flex items-center gap-2">
|
||||
@@ -150,7 +293,7 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
|
||||
|
||||
{/* 리스트 테이블 헤더 (board 변형에서는 숨김) */}
|
||||
{variant !== "board" && (
|
||||
<div className="hidden md:grid grid-cols-[20px_1fr_120px_120px_80px] items-center px-4 py-2 text-[12px] text-[#8c8c8c] bg-[#f6f4f4] border-b border-[#e6e6e6] rounded-t-xl">
|
||||
<div className="hidden md:grid grid-cols-[20px_1fr_120px_120px_80px] items-center px-4 py-2 text-[12px] text-[#8c8c8c] bg-[#f6f4f4] border-b border-[#e6e6e6] rounded-t-xl md:divide-x md:divide-[#e6e6e6]">
|
||||
<div />
|
||||
<div>제목</div>
|
||||
<div className="text-center">작성자</div>
|
||||
@@ -168,23 +311,54 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
|
||||
|
||||
{/* 아이템들 */}
|
||||
<div ref={listContainerRef} style={{ minHeight: lockedMinHeight ? `${lockedMinHeight}px` : undefined }}>
|
||||
<ul className="divide-y divide-[#ececec]">
|
||||
<ul className="divide-y divide-[#bbbbbb]">
|
||||
{items.map((p) => (
|
||||
<li key={p.id} className={`px-4 ${variant === "board" ? (compact ? "py-1.5" : "py-2.5") : "py-3 md:py-3"} hover:bg-neutral-50 transition-colors`}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-[20px_1fr_120px_120px_80px] items-center gap-2">
|
||||
<li key={p.id} className={`px-4 ${variant === "board" ? "py-3 md:py-4" : "py-4 md:py-4"} hover:bg-neutral-50 transition-colors min-h-[52px] md:min-h-[56px]`}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-[20px_1fr_120px_120px_80px] items-center gap-2 md:divide-x md:divide-[#eaeaea]">
|
||||
{/* bullet/공지 아이콘 자리 */}
|
||||
<div className="hidden md:flex items-center justify-center text-[#f94b37]">{p.isPinned ? "★" : "•"}</div>
|
||||
<div className="hidden md:flex items-center justify-center text-[#f94b37]"></div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<Link href={`/posts/${p.id}`} className={`group block truncate text-neutral-900`}>
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className={`group block truncate text-neutral-900 cursor-pointer ${variant === "board" ? "py-[8px]" : ""}`}
|
||||
onClick={() => router.push(`/posts/${p.id}`)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/posts/${p.id}`); }}
|
||||
>
|
||||
{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>}
|
||||
<span className={`${titleHoverOrange ? "group-hover:text-[var(--red-50,#F94B37)] group-hover:font-[700] group-hover:text-[16px] group-hover:leading-[22px]" : ""} ${compact ? "text-[14px] leading-[20px]" : "text-[15px] md:text-base"}`} style={{ fontFamily: "var(--font-family-Font-1, Pretendard)" }}>
|
||||
{/* 게시판 정보 배지: 보드 지정 리스트가 아닐 때만 표시 */}
|
||||
{!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
|
||||
? "text-[16px] leading-[16px] font-[400] text-[#161616]"
|
||||
: compact
|
||||
? "text-[14px] leading-[20px]"
|
||||
: "text-[15px] md:text-base"
|
||||
} ${
|
||||
titleHoverOrange
|
||||
? variant === "board"
|
||||
? "group-hover:text-[var(--red-50,#F94B37)] group-hover:font-[700] group-hover:text-[16px] group-hover:leading-[16px]"
|
||||
: "group-hover:text-[var(--red-50,#F94B37)] group-hover:font-[700]"
|
||||
: ""
|
||||
}`}
|
||||
style={{ fontFamily: "var(--font-family-Font-1, Pretendard)" }}
|
||||
>
|
||||
{stripHtml(p.title)}
|
||||
</span>
|
||||
{(p.stat?.commentsCount ?? 0) > 0 && (
|
||||
<span className="ml-1 text-[12px] md:text-[12px] text-[#f94b37] align-middle">[{p.stat?.commentsCount}]</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
{!!p.postTags?.length && (
|
||||
<div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500">
|
||||
{p.postTags?.map((pt) => (
|
||||
@@ -195,9 +369,11 @@ 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">
|
||||
<div className="md:w-[120px] text-xs text-[#8c8c8c] text-center flex items-center justify-center gap-3">
|
||||
<span className="inline-flex items-center gap-1"><ViewsIcon width={16} height={16} />{p.stat?.views ?? 0}</span>
|
||||
<span className="inline-flex items-center gap-1"><LikeIcon width={16} height={16} />{p.stat?.recommendCount ?? 0}</span>
|
||||
<span className="inline-flex items-center gap-1"><CommentIcon width={16} height={16} />{p.stat?.commentsCount ?? 0}</span>
|
||||
@@ -214,7 +390,7 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
|
||||
variant === "board" ? (
|
||||
<div className="mt-4 px-4 space-y-3">
|
||||
{/* 상단: 페이지 이동 컨트롤 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Previous */}
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -228,7 +404,7 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
|
||||
}
|
||||
}}
|
||||
disabled={page <= 1}
|
||||
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50"
|
||||
className="inline-flex items-center justify-center gap-1 h-[36px] px-[12px] py-[8px] rounded-[6px] border border-transparent bg-transparent text-[14px] text-[#161616] disabled:opacity-50 cursor-pointer hover:bg-neutral-100"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
@@ -261,12 +437,12 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
|
||||
}
|
||||
}}
|
||||
aria-current={n === page ? "page" : undefined}
|
||||
className={`h-9 w-9 rounded-md border ${n === page ? "border-neutral-300 text-[#f94b37] font-semibold" : "border-neutral-300 text-neutral-900"}`}
|
||||
className={`inline-flex items-center justify-center h-[36px] w-[36px] rounded-[6px] text-[14px] cursor-pointer ${n === page ? "border border-[#d5d5d5] bg-white text-[#f94b37] font-semibold hover:bg-neutral-100" : "border border-transparent bg-transparent text-[#161616] hover:bg-neutral-100"}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
) : (
|
||||
<span key={`e-${idx}`} className="h-9 w-9 inline-flex items-center justify-center text-neutral-900">…</span>
|
||||
<span key={`e-${idx}`} className="inline-flex items-center justify-center h-[36px] w-[36px] text-[#161616]">…</span>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
@@ -284,7 +460,7 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
|
||||
}
|
||||
}}
|
||||
disabled={page >= totalPages}
|
||||
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50"
|
||||
className="inline-flex items-center justify-center gap-1 h-[36px] px-[12px] py-[8px] rounded-[6px] border border-transparent bg-transparent text-[14px] text-[#161616] disabled:opacity-50 cursor-pointer hover:bg-neutral-100"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
@@ -293,31 +469,14 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
|
||||
<div className="flex items-center justify-between md:justify-end gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-600">표시 개수</span>
|
||||
<select
|
||||
value={currentPageSize}
|
||||
onChange={(e) => {
|
||||
const newSize = parseInt(e.target.value, 10);
|
||||
setCurrentPageSize(newSize);
|
||||
setPage(1);
|
||||
const nextSp = new URLSearchParams(Array.from(sp.entries()));
|
||||
nextSp.set("pageSize", String(newSize));
|
||||
nextSp.set("page", "1");
|
||||
if (typeof window !== "undefined") {
|
||||
window.history.replaceState(null, "", `?${nextSp.toString()}`);
|
||||
}
|
||||
}}
|
||||
className="h-9 px-2 rounded-md border border-neutral-300 bg-white text-sm"
|
||||
>
|
||||
<option value="10">10개</option>
|
||||
<option value="20">20개</option>
|
||||
<option value="30">30개</option>
|
||||
<option value="40">40개</option>
|
||||
<option value="50">50개</option>
|
||||
</select>
|
||||
<PageSizeDropdown />
|
||||
</div>
|
||||
{newPostHref && (
|
||||
<Link href={newPostHref} className="shrink-0">
|
||||
<button className="h-9 px-4 rounded-[10px] bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95">글쓰기</button>
|
||||
<button className="inline-flex justify-center items-center gap-1 shrink-0 h-[36px] px-[12px] py-[6px] rounded-[10px] border border-[#d73b29] bg-[#f94b37] hover:bg-[#d73b29] text-white text-sm cursor-pointer">
|
||||
<PlusIcon width={16} height={16} fill="white" />
|
||||
<span>글쓰기</span>
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
66
src/app/components/ProfileImageEditor.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { UploadButton } from "@/app/components/UploadButton";
|
||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||
|
||||
type Props = {
|
||||
initialUrl?: string | null;
|
||||
onUrlChange?: (url: string | null) => void;
|
||||
hideUrlText?: boolean;
|
||||
};
|
||||
|
||||
export function ProfileImageEditor({ initialUrl, onUrlChange, hideUrlText }: Props) {
|
||||
const { show } = useToast();
|
||||
const [url, setUrl] = React.useState<string>(initialUrl || "");
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
async function save(newUrl: string | null) {
|
||||
try {
|
||||
setSaving(true);
|
||||
const res = await fetch("/api/me/profile-image", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: newUrl }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error || "저장 실패");
|
||||
show("프로필 이미지가 저장되었습니다");
|
||||
setUrl(newUrl || "");
|
||||
onUrlChange?.(newUrl);
|
||||
} catch (e: any) {
|
||||
show(e.message || "저장 실패");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex gap-2">
|
||||
<UploadButton
|
||||
onUploaded={(u) => {
|
||||
// 정사각 권장
|
||||
save(u);
|
||||
}}
|
||||
aspectRatio={1}
|
||||
maxWidth={512}
|
||||
maxHeight={512}
|
||||
quality={0.9}
|
||||
/>
|
||||
{url ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => save(null)}
|
||||
className="px-3 py-2 border border-neutral-300 rounded-md text-sm"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "처리 중..." : "이미지 제거"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{!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,13 +14,14 @@ export function SearchBar() {
|
||||
}}
|
||||
role="search"
|
||||
aria-label="사이트 검색"
|
||||
className="relative w-full max-w-[384px]"
|
||||
className={`relative w-full group ${fullWidth ? "max-w-none" : "max-w-[384px]"} ${className}`}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="검색 실행"
|
||||
className="absolute right-2 top-2 w-8 h-8 text-neutral-500 hover:text-neutral-800 cursor-pointer"
|
||||
className="absolute right-2 top-2 w-8 h-8 text-neutral-500 cursor-pointer"
|
||||
>
|
||||
{/* 기본(일반) 상태: outline 아이콘 */}
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
@@ -28,7 +29,7 @@ export function SearchBar() {
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
className="w-8 h-8"
|
||||
className="w-8 h-8 block group-hover:hidden group-focus-within:hidden"
|
||||
>
|
||||
<path
|
||||
d="M21 21L17.682 17.682M17.682 17.682C18.4963 16.8676 19 15.7426 19 14.5C19 12.0147 16.9853 10 14.5 10C12.0147 10 10 12.0147 10 14.5C10 16.9853 12.0147 19 14.5 19C15.7426 19 16.8676 18.4963 17.682 17.682ZM28 16C28 22.6274 22.6274 28 16 28C9.37258 28 4 22.6274 4 16C4 9.37258 9.37258 4 16 4C22.6274 4 28 9.37258 28 16Z"
|
||||
@@ -38,6 +39,20 @@ export function SearchBar() {
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Hover/Focus 상태: 제공된 solid 아이콘 */}
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
className="w-8 h-8 hidden group-hover:block group-focus-within:block"
|
||||
>
|
||||
<path d="M11 14.5C11 12.567 12.567 11 14.5 11C16.433 11 18 12.567 18 14.5C18 15.4668 17.6093 16.3404 16.9749 16.9749C16.3404 17.6093 15.4668 18 14.5 18C12.567 18 11 16.433 11 14.5Z" fill="#707070" />
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M16 3C8.8203 3 3 8.8203 3 16C3 23.1797 8.8203 29 16 29C23.1797 29 29 23.1797 29 16C29 8.8203 23.1797 3 16 3ZM14.5 9C11.4624 9 9 11.4624 9 14.5C9 17.5376 11.4624 20 14.5 20C15.6571 20 16.7316 19.6419 17.6174 19.0316L20.2929 21.7071C20.6834 22.0976 21.3166 22.0976 21.7071 21.7071C22.0976 21.3166 22.0976 20.6834 21.7071 20.2929L19.0316 17.6174C19.6419 16.7316 20 15.6571 20 14.5C20 11.4624 17.5376 9 14.5 9Z" fill="#707070" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
type="search"
|
||||
@@ -50,7 +65,7 @@ export function SearchBar() {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setTerm("");
|
||||
}}
|
||||
className="w-full h-12 pr-12 pl-2 rounded-2xl border border-neutral-300 bg-white"
|
||||
className="w-full h-12 pr-12 pl-2 rounded-2xl border bg-white border-neutral-300 hover:border-[2px] hover:border-neutral-500 focus:border-2 focus:border-neutral-800 focus:outline-none transition-colors"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
30
src/app/components/SendMessageButton.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useMessageModal } from "@/app/components/ui/MessageModalProvider";
|
||||
|
||||
type Props = {
|
||||
receiverId: string;
|
||||
receiverNickname?: string | null;
|
||||
className?: string;
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
export function SendMessageButton({ receiverId, receiverNickname, className, size = "sm" }: Props) {
|
||||
const { openMessageModal } = useMessageModal();
|
||||
const padding = size === "md" ? "px-3 py-1.5" : "px-2 py-1";
|
||||
const text = size === "md" ? "text-sm" : "text-xs";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openMessageModal({ receiverId, receiverNickname: receiverNickname ?? null })}
|
||||
className={`inline-flex items-center rounded-md border border-neutral-300 bg-white ${padding} ${text} text-neutral-800 hover:bg-neutral-100 cursor-pointer ${className || ""}`}
|
||||
aria-label="쪽지 보내기"
|
||||
>
|
||||
쪽지 보내기
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
68
src/app/components/SendMessageForm.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||
|
||||
export function SendMessageForm({ receiverId, receiverNickname, onSent }: { receiverId: string; receiverNickname?: string | null; onSent?: () => void }) {
|
||||
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("쪽지를 보냈습니다");
|
||||
onSent?.();
|
||||
} 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 || "알 수 없음"}</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
45
src/app/components/UnreadMessagesModal.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Modal } from "@/app/components/ui/Modal";
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export default function UnreadMessagesModal({ count }: Props) {
|
||||
const [open, setOpen] = React.useState(true);
|
||||
const onClose = () => setOpen(false);
|
||||
if (!open || count <= 0) return null;
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<div className="w-[420px] max-w-[92vw] rounded-lg border border-neutral-200 shadow-xl">
|
||||
<div className="px-5 py-4 border-b border-neutral-200">
|
||||
<h3 className="text-base font-semibold text-neutral-900">알림</h3>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<p className="text-sm text-neutral-800">
|
||||
안읽은 쪽지가 있습니다{count > 0 ? ` (${count}개)` : ""}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 pb-5 flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
<Link
|
||||
href="/my-page?tab=messages-received"
|
||||
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800 inline-flex items-center justify-center text-center"
|
||||
>
|
||||
바로 확인
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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} onSent={close} />
|
||||
</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,7 +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 { AutoLoginAdmin } from "@/app/components/AutoLoginAdmin";
|
||||
import { MessageModalProvider } from "@/app/components/ui/MessageModalProvider";
|
||||
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -23,9 +23,9 @@ export default function RootLayout({
|
||||
<body className="min-h-screen bg-background text-foreground antialiased">
|
||||
<QueryProvider>
|
||||
<ToastProvider>
|
||||
<AutoLoginAdmin />
|
||||
<MessageModalProvider>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<div className="sticky top-0 z-50 bg-white/80 backdrop-blur">
|
||||
<div className="sticky top-0 z-50 bg-white/90 backdrop-blur">
|
||||
<div className="mx-auto w-full">
|
||||
<Suspense fallback={null}>
|
||||
<AppHeader />
|
||||
@@ -33,7 +33,7 @@ export default function RootLayout({
|
||||
</div>
|
||||
</div>
|
||||
<main className="flex-1 bg-[#F2F2F2]">
|
||||
<div className="max-w-[1920px] mx-auto px-4 py-6">
|
||||
<div className="max-w-[1500px] mx-auto px-4 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
@@ -43,6 +43,7 @@ export default function RootLayout({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MessageModalProvider>
|
||||
</ToastProvider>
|
||||
</QueryProvider>
|
||||
</body>
|
||||
|
||||
@@ -3,12 +3,15 @@ import Link from "next/link";
|
||||
import React from "react";
|
||||
import { Button } from "@/app/components/ui/Button";
|
||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { show } = useToast();
|
||||
const [nickname, setNickname] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const sp = useSearchParams();
|
||||
const next = sp?.get("next") || "/";
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -16,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 = "/";
|
||||
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);
|
||||
}
|
||||
@@ -33,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 }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import prisma from "@/lib/prisma";
|
||||
import Link from "next/link";
|
||||
import { UserAvatar } from "@/app/components/UserAvatar";
|
||||
@@ -6,12 +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";
|
||||
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: {
|
||||
@@ -26,35 +30,38 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
|
||||
try {
|
||||
const h = await headers();
|
||||
const cookieHeader = h.get("cookie") || "";
|
||||
let isAdmin = false;
|
||||
const uid = cookieHeader
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.find((pair) => pair.startsWith("uid="))
|
||||
?.split("=")[1];
|
||||
const isAdminVal = cookieHeader
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.find((pair) => pair.startsWith("isAdmin="))
|
||||
?.split("=")[1];
|
||||
isAdmin = isAdminVal === "1";
|
||||
|
||||
if (!uid) {
|
||||
redirect(`/login?next=/my-page`);
|
||||
}
|
||||
if (uid) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { userId: decodeURIComponent(uid) },
|
||||
select: { userId: true, nickname: true, profileImage: true, points: true, level: true, grade: true },
|
||||
});
|
||||
if (user) currentUser = user;
|
||||
if (user) {
|
||||
currentUser = { ...user, isAdmin } as any;
|
||||
} else {
|
||||
redirect(`/login?next=/my-page`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 에러 무시
|
||||
}
|
||||
|
||||
// 로그인되지 않은 경우 어드민 사용자 가져오기
|
||||
if (!currentUser) {
|
||||
const admin = await prisma.user.findUnique({
|
||||
where: { nickname: "admin" },
|
||||
select: { userId: true, nickname: true, profileImage: true, points: true, level: true, grade: true },
|
||||
});
|
||||
if (admin) currentUser = admin;
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return <div className="p-4">로그인이 필요합니다.</div>;
|
||||
}
|
||||
if (!currentUser) redirect(`/login?next=/my-page`);
|
||||
|
||||
// 통계 정보 가져오기
|
||||
const [postsCount, commentsCount, receivedMessagesCount, sentMessagesCount] = await Promise.all([
|
||||
@@ -84,7 +91,12 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
|
||||
<div className="space-y-6">
|
||||
{/* 히어로 배너 */}
|
||||
<section>
|
||||
<HeroBanner />
|
||||
<HeroBanner
|
||||
subItems={[
|
||||
{ id: "my-page", name: "마이페이지", href: "/my-page" },
|
||||
]}
|
||||
activeSubId="my-page"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 프로필 섹션 (모바일 대응) */}
|
||||
@@ -113,13 +125,21 @@ 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 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.7188 3.125L13.5938 4.99999L5.3125 13.2812L3.4375 11.4062L11.7188 3.125ZM14.0625 1.5625L15.3125 2.8125C15.625 3.12499 15.625 3.62499 15.3125 3.93749L13.4375 5.81249L11.5625 3.93749L13.4375 2.06249C13.75 1.74999 14.25 1.74999 14.5625 2.06249L14.0625 1.5625ZM3.125 14.375L9.0625 12.1875L7.1875 10.3125L3.125 14.375Z" fill="#707070"/>
|
||||
</svg>
|
||||
</button>
|
||||
<ProfileEditTrigger initialProfileImageUrl={currentUser.profileImage || null} />
|
||||
</div>
|
||||
{/* 관리자 설정 버튼 */}
|
||||
{"isAdmin" in (currentUser as any) && (currentUser as any).isAdmin && (
|
||||
<div className="mt-1">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800"
|
||||
>
|
||||
관리자설정
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 프로필 수정은 연필 아이콘(모달)에서 처리 */}
|
||||
{/* 레벨/등급/포인트 정보 - 가로 배치 */}
|
||||
<div className="flex gap-3 md:gap-[16px] items-center justify-center">
|
||||
<div className="flex items-center gap-[4px] min-w-0">
|
||||
@@ -254,16 +274,76 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{activeTab === "messages-received" && (
|
||||
{activeTab === "messages-received" && (async () => {
|
||||
const pageSize = 20;
|
||||
// 받은 쪽지함 진입 시 미읽은 메시지를 읽음 처리
|
||||
await prisma.message.updateMany({
|
||||
where: { receiverId: currentUser.userId, readAt: null },
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
const messages = await prisma.message.findMany({
|
||||
where: { receiverId: currentUser.userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: pageSize,
|
||||
skip: (page - 1) * pageSize,
|
||||
select: { id: true, body: true, createdAt: true, sender: { select: { userId: true, nickname: true } } },
|
||||
});
|
||||
return (
|
||||
<div className="bg-white rounded-[24px] p-4">
|
||||
<div className="text-center text-neutral-500 py-10">받은 쪽지함 기능은 준비 중입니다.</div>
|
||||
<ul className="divide-y divide-neutral-200">
|
||||
{messages.map((m) => (
|
||||
<li key={m.id} className="py-3 flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-neutral-800">
|
||||
<Link href={`/users/${m.sender.userId}`} className="font-semibold hover:underline">{m.sender.nickname || "알 수 없음"}</Link>
|
||||
<span className="text-xs text-neutral-500 ml-2">{new Date(m.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "messages-sent" && (
|
||||
<div className="bg-white rounded-[24px] p-4">
|
||||
<div className="text-center text-neutral-500 py-10">보낸 쪽지함 기능은 준비 중입니다.</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>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||
import Link from "next/link";
|
||||
// PartnerCategorySection removed per request
|
||||
import HorizontalCardScroller from "@/app/components/HorizontalCardScroller";
|
||||
import PartnerScroller from "@/app/components/PartnerScroller";
|
||||
import Link from "next/link";
|
||||
import { PostList } from "@/app/components/PostList";
|
||||
import ProfileLabelIcon from "@/app/svgs/profilelableicon";
|
||||
import SearchIcon from "@/app/svgs/SearchIcon";
|
||||
@@ -13,6 +15,8 @@ import { BoardPanelClient } from "@/app/components/BoardPanelClient";
|
||||
import { GradeIcon, getGradeName } from "@/app/components/GradeIcon";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { headers } from "next/headers";
|
||||
import { InlineLoginForm } from "@/app/components/InlineLoginForm";
|
||||
import UnreadMessagesModal from "@/app/components/UnreadMessagesModal";
|
||||
|
||||
export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: "recent" | "popular" } | undefined> }) {
|
||||
const sp = await searchParams;
|
||||
@@ -48,21 +52,18 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
||||
// 에러 무시
|
||||
}
|
||||
|
||||
// 로그인되지 않은 경우 어드민 사용자 가져오기
|
||||
if (!currentUser) {
|
||||
const admin = await prisma.user.findUnique({
|
||||
where: { nickname: "admin" },
|
||||
select: { userId: true, nickname: true, profileImage: true, points: true, level: true, grade: true },
|
||||
});
|
||||
if (admin) currentUser = admin;
|
||||
}
|
||||
// 로그인되지 않은 경우 어드민 사용자로 대체하지 않음 (요청사항)
|
||||
|
||||
// 내가 쓴 게시글/댓글 수
|
||||
let myPostsCount = 0;
|
||||
let myCommentsCount = 0;
|
||||
let unreadMessagesCount = 0;
|
||||
if (currentUser) {
|
||||
myPostsCount = await prisma.post.count({ where: { authorId: currentUser.userId, status: "published" } });
|
||||
myCommentsCount = await prisma.comment.count({ where: { authorId: currentUser.userId } });
|
||||
unreadMessagesCount = await prisma.message.count({
|
||||
where: { receiverId: currentUser.userId, readAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
// 메인페이지 설정 불러오기
|
||||
@@ -76,7 +77,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
||||
// 보드 메타데이터 (메인뷰 타입 포함)
|
||||
const boardsMeta = visibleBoardIds.length
|
||||
? await prisma.board.findMany({
|
||||
where: { id: { in: visibleBoardIds } },
|
||||
where: { id: { in: visibleBoardIds }, status: "active" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
@@ -115,14 +116,26 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
||||
// 게시판 패널 데이터 수집 함수 (모든 sibling boards 포함)
|
||||
const prepareBoardPanelData = async (board: { id: string; name: string; slug: string; categoryId: string | null; categoryName: string; mainTypeKey?: string }) => {
|
||||
// 같은 카테고리의 소분류(게시판) 탭 목록
|
||||
const siblingBoards = board.categoryId
|
||||
let siblingBoards: any[] = board.categoryId
|
||||
? await prisma.board.findMany({
|
||||
where: { categoryId: board.categoryId },
|
||||
where: { categoryId: board.categoryId, status: "active" },
|
||||
select: { id: true, name: true, slug: true, mainPageViewType: { select: { key: true } } },
|
||||
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
|
||||
})
|
||||
: [];
|
||||
|
||||
// 카테고리가 없거나 동료 게시판이 없을 때, 선택된 보드 단독 구성으로 대체
|
||||
if (siblingBoards.length === 0) {
|
||||
siblingBoards = [
|
||||
{
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
slug: board.slug,
|
||||
mainPageViewType: { key: board.mainTypeKey },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const boardsData = [];
|
||||
|
||||
for (const sb of siblingBoards) {
|
||||
@@ -197,37 +210,22 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{currentUser && unreadMessagesCount > 0 && (
|
||||
<UnreadMessagesModal count={unreadMessagesCount} />
|
||||
)}
|
||||
{/* 히어로 섹션: 상단 대형 비주얼 영역 (설정 온오프) */}
|
||||
{showBanner && (
|
||||
<section>
|
||||
<HeroBanner />
|
||||
<HeroBanner showPartnerCats={showPartnerShops} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 배너 아래: 카테고리 탭 섹션 제거됨 */}
|
||||
|
||||
{/* 제휴 샾 가로 스크롤 (설정 온오프, DB에서 불러오기)
|
||||
- 우선 partners 테이블(관리자 페이지 관리 대상) 사용
|
||||
- 없으면 partner_shops로 대체 */}
|
||||
{showPartnerShops && (async () => {
|
||||
// 우선순위: partners(관리자 관리) → partner_shops(폴백)
|
||||
let partners: any[] = [];
|
||||
try {
|
||||
partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], take: 10 });
|
||||
} catch (_) {
|
||||
partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" }, take: 10 });
|
||||
}
|
||||
const items = partners.map((p: any) => ({
|
||||
id: p.id,
|
||||
region: p.address ? String(p.address).split(" ")[0] : p.category,
|
||||
name: p.name,
|
||||
address: p.address || "",
|
||||
image: p.imageUrl || "/sample.jpg",
|
||||
}));
|
||||
if (items.length > 0) return <HorizontalCardScroller items={items} />;
|
||||
|
||||
const shops = await (prisma as any).partnerShop.findMany({ where: { active: true }, orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }] });
|
||||
const shopItems = shops.map((s: any) => ({ id: s.id, region: s.region, name: s.name, address: s.address, image: s.imageUrl }));
|
||||
return <HorizontalCardScroller items={shopItems} />;
|
||||
})()}
|
||||
{showPartnerShops && <PartnerScroller />}
|
||||
|
||||
{/* 1행: 프로필 + 선택된 보드 2개 (최대 2개) */}
|
||||
{(firstTwo.length > 0) && (
|
||||
@@ -235,45 +233,45 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:flex xl:gap-[23px] gap-4 h-full min-h-0">
|
||||
<div className="hidden xl:grid relative overflow-hidden rounded-xl bg-white px-[25px] py-[34px] grid-rows-[120px_120px_1fr] gap-y-[32px] h-[514px] w-[350px] shrink-0">
|
||||
<div className="absolute inset-x-0 top-0 h-[56px] bg-[#d5d5d5] z-0" />
|
||||
{currentUser ? (
|
||||
<>
|
||||
<div className="h-[120px] flex items-center justify-center relative z-10">
|
||||
<div className="flex items-center justify-center gap-[8px]">
|
||||
<UserAvatar
|
||||
src={currentUser?.profileImage || null}
|
||||
alt={currentUser?.nickname || "프로필"}
|
||||
src={currentUser.profileImage || null}
|
||||
alt={currentUser.nickname || "프로필"}
|
||||
width={120}
|
||||
height={120}
|
||||
className="rounded-full"
|
||||
/>
|
||||
{currentUser && (
|
||||
<div className="w-[62px] h-[62px] flex items-center justify-center">
|
||||
<GradeIcon grade={currentUser.grade} width={62} height={62} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[120px] flex flex-col items-center relative z-10">
|
||||
<div className="text-[18px] text-[#5c5c5c] font-[700] truncate text-center mb-[20px]">{currentUser?.nickname || "사용자"}</div>
|
||||
<div className="text-[18px] text-[#5c5c5c] font-[700] truncate text-center mb-[20px]">{currentUser.nickname}</div>
|
||||
<div className="w-[300px] pl-[67px] flex flex-col gap-[12px]">
|
||||
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
||||
<div className="w-[64px] flex items-center">
|
||||
<ProfileLabelIcon width={16} height={16} />
|
||||
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">레벨</span>
|
||||
</div>
|
||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">Lv. {currentUser?.level || 1}</div>
|
||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">Lv. {currentUser.level}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
||||
<div className="w-[64px] flex items-center">
|
||||
<ProfileLabelIcon width={16} height={16} />
|
||||
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">등급</span>
|
||||
</div>
|
||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">{getGradeName(currentUser?.grade || 0)}</div>
|
||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">{getGradeName(currentUser.grade)}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
||||
<div className="w-[64px] flex items-center">
|
||||
<ProfileLabelIcon width={16} height={16} />
|
||||
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">포인트</span>
|
||||
</div>
|
||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">{(currentUser?.points || 0).toLocaleString()}</div>
|
||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">{currentUser.points.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -286,12 +284,13 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/my-page?tab=points" className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
|
||||
<Link href={`/my-page?tab=messages-received`} className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
|
||||
<span className="flex items-center w-full pl-[88px]">
|
||||
<span className="flex items-center gap-[8px]">
|
||||
<SearchIcon width={16} height={16} />
|
||||
<span>포인트 히스토리</span>
|
||||
<span>새로운 쪽지</span>
|
||||
</span>
|
||||
<span className="ml-auto inline-flex items-center justify-center h-[20px] px-[8px] rounded-full bg-white text-[#5c5c5c] text-[12px] leading-[20px] shrink-0">{unreadMessagesCount.toLocaleString()}개</span>
|
||||
</span>
|
||||
</Link>
|
||||
<Link href={`/my-page?tab=posts`} className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
|
||||
@@ -313,6 +312,13 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
<InlineLoginForm next="/" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(await Promise.all(firstTwo.map((b) => prepareBoardPanelData(b)))).map((panelData, idx) => (
|
||||
<div key={firstTwo[idx].id} className="overflow-hidden xl:h-[514px] h-full min-h-0 flex flex-col flex-1">
|
||||
|
||||
@@ -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>
|
||||
@@ -195,7 +198,7 @@ export function RelatedPosts({ boardId, boardName, currentPostId, pageSize = 10
|
||||
<button
|
||||
onClick={() => handlePageChange(Math.max(1, page - 1))}
|
||||
disabled={page <= 1}
|
||||
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50"
|
||||
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50 cursor-pointer hover:bg-neutral-100"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
@@ -220,7 +223,7 @@ export function RelatedPosts({ boardId, boardName, currentPostId, pageSize = 10
|
||||
key={`p-${n}-${idx}`}
|
||||
onClick={() => handlePageChange(n)}
|
||||
aria-current={n === page ? "page" : undefined}
|
||||
className={`h-9 w-9 rounded-md border ${n === page ? "border-neutral-300 text-[#f94b37] font-semibold" : "border-neutral-300 text-neutral-900"}`}
|
||||
className={`h-9 w-9 rounded-md border cursor-pointer ${n === page ? "border-neutral-300 text-[#f94b37] font-semibold hover:bg-neutral-100" : "border-neutral-300 text-neutral-900 hover:bg-neutral-100"}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
@@ -234,7 +237,7 @@ export function RelatedPosts({ boardId, boardName, currentPostId, pageSize = 10
|
||||
<button
|
||||
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50"
|
||||
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50 cursor-pointer hover:bg-neutral-100"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
@@ -244,7 +247,7 @@ export function RelatedPosts({ boardId, boardName, currentPostId, pageSize = 10
|
||||
<select
|
||||
value={currentPageSize}
|
||||
onChange={(e) => handlePageSizeChange(parseInt(e.target.value, 10))}
|
||||
className="h-9 px-2 rounded-md border border-neutral-300 bg-white text-sm"
|
||||
className="h-[36px] px-[12px] rounded-[6px] border border-[#d5d5d5] bg-white text-[14px] text-[#161616] cursor-pointer hover:bg-neutral-100"
|
||||
>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@ import { headers } from "next/headers";
|
||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||
import { RelatedPosts } from "./RelatedPosts";
|
||||
import prisma from "@/lib/prisma";
|
||||
import Link from "next/link";
|
||||
import { CommentSection } from "@/app/components/CommentSection";
|
||||
import { UserNameMenu } from "@/app/components/UserNameMenu";
|
||||
import { ViewTracker } from "./ViewTracker";
|
||||
import ViewsIcon from "@/app/svgs/ViewsIcon";
|
||||
import LikeIcon from "@/app/svgs/LikeIcon";
|
||||
|
||||
// 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다.
|
||||
export default async function PostDetail({ params }: { params: any }) {
|
||||
@@ -23,22 +28,90 @@ export default async function PostDetail({ params }: { params: any }) {
|
||||
const parsed = settingRow ? JSON.parse(settingRow.value as string) : {};
|
||||
const showBanner: boolean = parsed.showBanner ?? true;
|
||||
|
||||
// 현재 게시글이 속한 카테고리의 보드들을 서브카테고리로 구성
|
||||
let subItems:
|
||||
| { id: string; name: string; href: string }[]
|
||||
| undefined = undefined;
|
||||
if (post?.boardId) {
|
||||
const categories = await prisma.boardCategory.findMany({
|
||||
where: { status: "active" },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
include: {
|
||||
boards: {
|
||||
where: { status: "active" },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
select: { id: true, name: true, slug: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const categoryOfPost = categories.find((c) =>
|
||||
c.boards.some((b) => b.id === post.boardId)
|
||||
);
|
||||
if (categoryOfPost) {
|
||||
subItems = categoryOfPost.boards.map((b) => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
href: `/boards/${b.slug}`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const backHref = post?.board?.slug ? `/boards/${post.board.slug}` : "/";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ViewTracker postId={id} />
|
||||
{/* 상단 배너 */}
|
||||
{showBanner && (
|
||||
<section>
|
||||
<HeroBanner />
|
||||
<HeroBanner
|
||||
subItems={subItems}
|
||||
activeSubId={post?.boardId}
|
||||
showPartnerCats={false}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 목록으로 버튼 */}
|
||||
<div className="px-1">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="inline-flex items-center gap-1 h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm text-neutral-900 hover:bg-neutral-100"
|
||||
>
|
||||
← 목록으로
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 본문 카드 */}
|
||||
<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>}
|
||||
{/* 지표: 조회수 / 좋아요수 / 댓글수 */}
|
||||
{(() => {
|
||||
const views = post?.stat?.views ?? 0;
|
||||
const likes = post?.stat?.recommendCount ?? 0;
|
||||
const comments = post?.stat?.commentsCount ?? 0;
|
||||
return (
|
||||
<>
|
||||
<span aria-hidden>•</span>
|
||||
<span className="inline-flex items-center gap-1"><ViewsIcon width={14} height={14} />{views}</span>
|
||||
<span className="inline-flex items-center gap-1"><LikeIcon width={14} height={14} />{likes}</span>
|
||||
<span className="inline-flex items-center gap-1">댓글 {comments}</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-4 md:p-6">
|
||||
<div
|
||||
@@ -64,12 +137,14 @@ export default async function PostDetail({ params }: { params: any }) {
|
||||
|
||||
{/* 같은 게시판 게시글 목록 */}
|
||||
{post?.boardId && post?.board && (
|
||||
<section className="px-[0px] md:px-[30px]">
|
||||
<RelatedPosts
|
||||
boardId={post.boardId}
|
||||
boardName={post.board.name}
|
||||
currentPostId={id}
|
||||
pageSize={10}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useToast } from "@/app/components/ui/ToastProvider";
|
||||
import { UploadButton } from "@/app/components/UploadButton";
|
||||
import { Editor } from "@/app/components/Editor";
|
||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||
import useSWR from "swr";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NewPostPage() {
|
||||
const router = useRouter();
|
||||
@@ -14,8 +16,10 @@ export default function NewPostPage() {
|
||||
const initialBoardId = sp.get("boardId") ?? "";
|
||||
const boardSlug = sp.get("boardSlug") ?? undefined;
|
||||
const [form, setForm] = useState({ boardId: initialBoardId, title: "", content: "" });
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [isSecret, setIsSecret] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { data: meData } = useSWR<{ user: { userId: string } | null }>("/api/me", (u: string) => fetch(u).then((r) => r.json()));
|
||||
async function submit() {
|
||||
try {
|
||||
if (!form.boardId.trim()) {
|
||||
@@ -52,6 +56,40 @@ export default function NewPostPage() {
|
||||
}
|
||||
const plainLength = (form.content || "").replace(/<[^>]*>/g, "").length;
|
||||
const MAX_LEN = 10000;
|
||||
if (!meData) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<HeroBanner />
|
||||
</section>
|
||||
<section className="mx-auto max-w-2xl bg-white rounded-2xl border border-neutral-300 px-6 sm:px-8 py-8 text-center">
|
||||
<div className="text-lg font-semibold text-neutral-900">확인 중...</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!meData.user) {
|
||||
const next = `/posts/new${sp.toString() ? `?${sp.toString()}` : ""}`;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<HeroBanner />
|
||||
</section>
|
||||
<section className="mx-auto max-w-2xl bg-white rounded-2xl border border-neutral-300 px-6 sm:px-8 py-8 text-center">
|
||||
<div className="text-[22px] md:text-[26px] font-semibold text-neutral-900">로그인이 필요합니다</div>
|
||||
<p className="mt-2 text-neutral-600">게시글 작성은 로그인 후 이용할 수 있어요.</p>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
href={`/login?next=${encodeURIComponent(next)}`}
|
||||
className="inline-flex items-center px-5 h-12 rounded-xl border border-neutral-300 text-neutral-800 hover:bg-neutral-100"
|
||||
>
|
||||
로그인 하러 가기
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
@@ -107,16 +145,47 @@ export default function NewPostPage() {
|
||||
<span aria-hidden>🙂</span>
|
||||
<UploadButton
|
||||
multiple
|
||||
onUploaded={(url) => {
|
||||
const figure = `<figure style="margin:8px 0; position: relative; display: inline-block;" data-resizable="true"><img src="${url}" alt="" style="max-width: 100%; height: auto; display: block; cursor: pointer;" /><div class="resize-handle" style="position: absolute; bottom: 0; right: 0; width: 16px; height: 16px; background: #f94b37; cursor: se-resize; border-radius: 50% 0 0 0; opacity: 0.8;"></div><button class="delete-image-btn" style="position: absolute; top: 4px; right: 4px; width: 24px; height: 24px; background: rgba(0, 0, 0, 0.6); color: white; border: none; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; line-height: 1; opacity: 0.8;" title="이미지 삭제">×</button></figure>`;
|
||||
setForm((f) => ({ ...f, content: `${f.content}\n${figure}` }));
|
||||
}}
|
||||
onUploaded={(url) => setImages((prev) => [url, ...prev])}
|
||||
{...(boardSlug ? require("@/lib/photoPresets").getPhotoPresetBySlug(boardSlug) : {})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{images.length > 0 ? (
|
||||
<div className="mt-3">
|
||||
<div className="px-4 py-3 bg-neutral-50 border border-neutral-200 rounded-2xl">
|
||||
<div className="mb-2 text-sm text-neutral-700">업로드된 이미지</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{images.map((url) => (
|
||||
<div key={url} className="relative group border border-neutral-200 rounded-xl overflow-hidden bg-white">
|
||||
<img src={url} alt="" className="w-full h-36 object-cover" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-2 flex gap-2 justify-end opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
className="px-2.5 py-1 text-xs rounded-md border border-neutral-300 bg-white hover:bg-neutral-100"
|
||||
onClick={() => {
|
||||
const imgHtml = `<p><img src="${url}" alt="" style="max-width: 100%; height: auto; display: block; margin: 8px 0;" /></p>`;
|
||||
setForm((f) => ({ ...f, content: `${f.content}\n${imgHtml}` }));
|
||||
}}
|
||||
>
|
||||
본문에 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-2.5 py-1 text-xs rounded-md border border-neutral-300 bg-white hover:bg-neutral-100"
|
||||
onClick={() => setImages((prev) => prev.filter((u) => u !== url))}
|
||||
>
|
||||
제거
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
disabled={loading}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import useSWR from "swr";
|
||||
import { UserNameMenu } from "@/app/components/UserNameMenu";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
@@ -19,7 +20,10 @@ export default function RankingPage({ searchParams }: { searchParams?: { period?
|
||||
<ol>
|
||||
{(data?.items ?? []).map((i) => (
|
||||
<li key={i.userId}>
|
||||
<strong>{i.nickname}</strong> — {i.points}점
|
||||
<strong>
|
||||
<UserNameMenu userId={i.userId} nickname={i.nickname} underlineOnHover={false} />
|
||||
</strong>{" "}
|
||||
— {i.points}점
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
@@ -2,43 +2,113 @@
|
||||
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();
|
||||
const [form, setForm] = React.useState({
|
||||
nickname: "",
|
||||
name: "",
|
||||
phone: "",
|
||||
birth: "",
|
||||
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[]>;
|
||||
setErrors(fieldErrors);
|
||||
if (fieldErrors.nickname?.length || fieldErrors.name?.length) {
|
||||
setErrors((errs) => ({ ...errs, ...fieldErrors }));
|
||||
const msg = fieldErrors.nickname?.[0] || fieldErrors.name?.[0] || "회원가입 실패";
|
||||
throw new Error(msg);
|
||||
} else {
|
||||
setErrors((errs) => ({ ...errs, nickname: ["이미 사용 중인 아이디입니다"] }));
|
||||
throw new Error("이미 사용 중인 아이디입니다");
|
||||
}
|
||||
}
|
||||
const fieldErrors = (data?.error?.fieldErrors ?? {}) as Record<string, string[]>;
|
||||
if (Object.keys(fieldErrors).length) setErrors(fieldErrors);
|
||||
const msg = data?.error?.message || Object.values(fieldErrors)[0]?.[0] || "회원가입 실패";
|
||||
throw new Error(msg);
|
||||
}
|
||||
setErrors({});
|
||||
show("회원가입 성공! 로그인해주세요");
|
||||
show("회원가입이 완료되었습니다");
|
||||
setTimeout(() => {
|
||||
location.href = "/login";
|
||||
}, 1200);
|
||||
} catch (err: any) {
|
||||
show(err.message || "회원가입 실패");
|
||||
} finally {
|
||||
@@ -49,34 +119,76 @@ 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="phone" placeholder="전화번호" value={form.phone} onChange={onChange} aria-invalid={!!errors.phone} aria-describedby="err-phone" style={{ padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
||||
{errors.phone?.length ? (
|
||||
<span id="err-phone" style={{ color: "#c00", fontSize: 12 }}>{errors.phone[0]}</span>
|
||||
) : null}
|
||||
<input name="birth" placeholder="생년월일 (YYYY-MM-DD)" value={form.birth} onChange={onChange} aria-invalid={!!errors.birth} aria-describedby="err-birth" style={{ padding: 8, border: "1px solid #ddd", borderRadius: 6 }} />
|
||||
{errors.birth?.length ? (
|
||||
<span id="err-birth" style={{ color: "#c00", fontSize: 12 }}>{errors.birth[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}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<textarea
|
||||
readOnly
|
||||
value={`[이용 약관]\n\n본 서비스 이용 시 커뮤니티 가이드라인을 준수해야 하며, 불법/유해 콘텐츠 게시 금지 등 운영 정책을 따릅니다.\n개인정보는 서비스 제공 및 보안 목적에 한해 처리되며, 자세한 내용은 개인정보 처리방침을 확인하세요.`}
|
||||
style={{ width: "100%", minHeight: 140, padding: 8, border: "1px solid #ddd", borderRadius: 6, resize: "vertical", background: "#fafafa" }}
|
||||
/>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<input name="agreeTerms" type="checkbox" checked={form.agreeTerms} onChange={onChange} /> 약관에 동의합니다
|
||||
<input
|
||||
type="checkbox"
|
||||
name="agreeTerms"
|
||||
checked={form.agreeTerms}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<span style={{ fontSize: 14, color: "#333" }}>약관에 동의합니다</span>
|
||||
</label>
|
||||
<Button type="submit" disabled={loading}>{loading ? "가입 중..." : "가입하기"}</Button>
|
||||
{errors.agreeTerms?.length ? (
|
||||
<span id="err-agree" style={{ color: "#c00", fontSize: 12 }}>{errors.agreeTerms[0]}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="submit" disabled={loading || form.password !== form.confirmPassword}>{loading ? "가입 중..." : "가입하기"}</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
12
src/app/svgs/PlusIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
|
||||
export default function PlusIcon({ width = 16, height = 16, fill = "white", className }: { width?: number; height?: number; fill?: string; className?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 16 16" fill="none" className={className} aria-hidden>
|
||||
<path d="M7 14V9H2C1.44772 9 1 8.55228 1 8C1 7.44772 1.44772 7 2 7H7V2C7 1.44772 7.44772 1 8 1C8.55228 1 9 1.44772 9 2V7H14C14.5523 7 15 7.44772 15 8C15 8.55228 14.5523 9 14 9H9V14C9 14.5523 8.55228 15 8 15C7.44772 15 7 14.5523 7 14Z" fill={fill}/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
9
src/app/svgs/editicon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
|
||||
|
||||
|
||||
<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="white"/>
|
||||
|
||||
</svg>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 980 B |
94
src/app/users/[id]/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PostList } from "@/app/components/PostList";
|
||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||
import { UserAvatar } from "@/app/components/UserAvatar";
|
||||
import { GradeIcon, getGradeName } from "@/app/components/GradeIcon";
|
||||
import { SendMessageButton } from "@/app/components/SendMessageButton";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export default async function UserPublicProfile({ params }: { params: Promise<{ id: string }> }) {
|
||||
const p = await params;
|
||||
const userId = p.id;
|
||||
|
||||
// 현재 로그인한 사용자 식별 (uid 쿠키 기반)
|
||||
const headerList = await headers();
|
||||
const cookieHeader = headerList.get("cookie") || "";
|
||||
const currentUid =
|
||||
cookieHeader
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.find((pair) => pair.startsWith("uid="))
|
||||
?.split("=")[1] || "";
|
||||
const currentUserId = currentUid ? decodeURIComponent(currentUid) : null;
|
||||
|
||||
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} />
|
||||
{currentUserId && currentUserId !== user.userId && (
|
||||
<SendMessageButton receiverId={user.userId} receiverNickname={user.nickname} />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,23 +19,9 @@ export function getUserIdFromRequest(req: Request): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// 개발 환경에서만: 인증 정보가 없으면 admin 사용자 ID를 반환
|
||||
// DB 조회 결과를 프로세스 전역에 캐싱해 과도한 쿼리를 방지
|
||||
export async function getUserIdOrAdmin(req: Request): Promise<string | null> {
|
||||
const uid = getUserIdFromRequest(req);
|
||||
if (uid) return uid;
|
||||
if (process.env.NODE_ENV === "production") return null;
|
||||
try {
|
||||
const globalAny = global as unknown as { __ADMIN_UID?: string };
|
||||
if (globalAny.__ADMIN_UID) return globalAny.__ADMIN_UID;
|
||||
const admin = await prisma.user.findUnique({ where: { nickname: "admin" }, select: { userId: true } });
|
||||
if (admin?.userId) {
|
||||
globalAny.__ADMIN_UID = admin.userId;
|
||||
return admin.userId;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
// 비로그인 시 admin으로 대체하지 않고 null 반환 (익명 처리)
|
||||
return uid ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,23 @@ export async function checkPermission(options: {
|
||||
const { userId, resource, action } = options;
|
||||
if (!userId) return false;
|
||||
|
||||
const userRoles = await prisma.userRole.findMany({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { userId },
|
||||
select: { roleId: true },
|
||||
select: {
|
||||
authLevel: true,
|
||||
userRoles: { select: { roleId: true } },
|
||||
},
|
||||
});
|
||||
if (userRoles.length === 0) return false;
|
||||
const roleIds = userRoles.map((r) => r.roleId);
|
||||
if (!user) return false;
|
||||
|
||||
// 사용자 레코드가 ADMIN 권한이면 모든 리소스/액션 허용
|
||||
if (user.authLevel === "ADMIN") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const roleIds = user.userRoles.map((r) => r.roleId);
|
||||
if (roleIds.length === 0) return false;
|
||||
|
||||
const has = await prisma.rolePermission.findFirst({
|
||||
where: {
|
||||
roleId: { in: roleIds },
|
||||
@@ -25,6 +36,7 @@ export async function checkPermission(options: {
|
||||
select: { id: true },
|
||||
});
|
||||
if (has) return true;
|
||||
|
||||
// ADMIN.ADMINISTER 이면 모든 리소스/액션 허용
|
||||
const isAdmin = await prisma.rolePermission.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -1,19 +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),
|
||||
phone: z
|
||||
nickname: nicknameSchema,
|
||||
name: nameSchema,
|
||||
password: z
|
||||
.string()
|
||||
.regex(/^[0-9\-+]{9,15}$/)
|
||||
.transform((s) => s.replace(/[^0-9]/g, "")),
|
||||
birth: z
|
||||
.min(8, "비밀번호는 8자 이상이어야 합니다")
|
||||
.max(100, "비밀번호는 100자 이하이어야 합니다"),
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.refine((s) => !Number.isNaN(Date.parse(s)), { message: "Invalid date" }),
|
||||
password: z.string().min(8).max(100),
|
||||
confirmPassword: z.string().min(8).max(100),
|
||||
.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: "비밀번호가 일치하지 않습니다",
|
||||
@@ -23,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>;
|
||||
|
||||
@@ -8,24 +8,79 @@ const protectedApi = [
|
||||
export async function middleware(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
const response = NextResponse.next();
|
||||
|
||||
// 쿠키에 uid가 없으면 어드민으로 자동 로그인 (기본값)
|
||||
const uid = req.cookies.get("uid")?.value;
|
||||
if (!uid) {
|
||||
// 어드민 사용자 ID 가져오기 (DB 조회 대신 하드코딩 - 실제 환경에서는 다른 방식 사용 권장)
|
||||
// 어드민 nickname은 "admin"으로 고정되어 있다고 가정
|
||||
// 실제 userId는 DB에서 가져와야 하지만, middleware는 비동기 DB 호출을 제한적으로 지원
|
||||
// 대신 page.tsx에서 처리하도록 함
|
||||
const isAdmin = req.cookies.get("isAdmin")?.value;
|
||||
|
||||
// 페이지 요청일 경우만 쿠키 설정 시도
|
||||
// API는 제외하고 페이지만 처리
|
||||
if (!pathname.startsWith("/api")) {
|
||||
// 페이지 레벨에서 처리하도록 함 (쿠키는 클라이언트 측에서 설정 필요)
|
||||
// 세션 검증 엔드포인트 자체에 대해선 미들웨어 검증을 생략 (무한 루프 방지)
|
||||
if (pathname === "/api/auth/session") {
|
||||
return response;
|
||||
}
|
||||
|
||||
// uid 쿠키가 있어도 실제 유저가 존재하지 않으면 비로그인으로 간주하고 쿠키 정리
|
||||
// 기본값은 쿠키 존재 여부 기준(네트워크 오류 시 과도한 로그아웃 방지)
|
||||
let authenticated = !!uid;
|
||||
if (uid) {
|
||||
try {
|
||||
const verifyUrl = new URL("/api/auth/session", req.url);
|
||||
const verifyRes = await fetch(verifyUrl.toString(), {
|
||||
headers: {
|
||||
// 현재 요청의 쿠키를 전달하여 세션을 검증
|
||||
cookie: req.headers.get("cookie") ?? "",
|
||||
},
|
||||
});
|
||||
if (verifyRes.ok) {
|
||||
const data = await verifyRes.json().catch(() => ({ ok: false }));
|
||||
authenticated = !!data?.ok;
|
||||
} else {
|
||||
// 검증 API가 200이 아니면 기존 판단 유지(일시적 오류 대비)
|
||||
authenticated = !!uid;
|
||||
}
|
||||
} catch {
|
||||
// 네트워크/런타임 오류 시 기존 판단 유지(쿠키 있으면 로그인 간주)
|
||||
authenticated = !!uid;
|
||||
}
|
||||
// 유효하지 않은 uid 쿠키는 즉시 제거
|
||||
if (!authenticated) {
|
||||
response.cookies.set("uid", "", { path: "/", maxAge: 0 });
|
||||
response.cookies.set("isAdmin", "", { path: "/", maxAge: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
// Admin 페이지 보호
|
||||
if (pathname.startsWith("/admin")) {
|
||||
console.log("Admin 페이지 접근");
|
||||
console.log("uid", uid);
|
||||
// 비로그인 사용자는 로그인 페이지로
|
||||
if (!authenticated) {
|
||||
const loginUrl = req.nextUrl.clone();
|
||||
loginUrl.pathname = "/login";
|
||||
const res = NextResponse.redirect(loginUrl);
|
||||
// 리다이렉트 응답에도 쿠키 정리 반영
|
||||
if (uid && !authenticated) {
|
||||
res.cookies.set("uid", "", { path: "/", maxAge: 0 });
|
||||
res.cookies.set("isAdmin", "", { path: "/", maxAge: 0 });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
// 로그인했지만 관리자가 아니면 홈으로
|
||||
if (isAdmin !== "1") {
|
||||
const homeUrl = req.nextUrl.clone();
|
||||
homeUrl.pathname = "/";
|
||||
return NextResponse.redirect(homeUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 로그인된 상태에서 로그인 페이지 접근 시 홈으로 리다이렉트
|
||||
if (pathname === "/login" || pathname.startsWith("/login/")) {
|
||||
if (authenticated) {
|
||||
const homeUrl = req.nextUrl.clone();
|
||||
homeUrl.pathname = "/";
|
||||
return NextResponse.redirect(homeUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const needAuth = protectedApi.some((re) => re.test(pathname));
|
||||
if (needAuth && !uid) {
|
||||
if (needAuth && !authenticated) {
|
||||
return new NextResponse(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
@@ -36,4 +91,3 @@ export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"],
|
||||
};
|
||||
|
||||
|
||||
|
||||