Compare commits

...

18 Commits

Author SHA1 Message Date
koreacomp5
afc714022f 인기글 추가
All checks were successful
deploy-on-main / deploy (push) Successful in 28s
2025-11-28 06:12:58 +09:00
koreacomp5
c85450ce37 board notfound
All checks were successful
deploy-on-main / deploy (push) Successful in 37s
2025-11-28 05:29:34 +09:00
koreacomp5
c5bc8f5b49 check
All checks were successful
deploy-on-main / deploy (push) Successful in 26s
2025-11-10 21:58:36 +09:00
koreacomp5
14c80baeec ㄹㄹ
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-10 13:44:05 +09:00
koreacomp5
5287611bf7 test 제거
Some checks failed
deploy-on-main / deploy (push) Failing after 21s
2025-11-10 13:33:29 +09:00
koreacomp5
cb2d1f34d3 admin 권한 세팅
Some checks failed
deploy-on-main / deploy (push) Failing after 21s
2025-11-10 11:26:00 +09:00
koreacomp5
97c8e1c9fb build check
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-10 01:58:06 +09:00
koreacomp5
5485da4029 출석관련
Some checks failed
deploy-on-main / deploy (push) Failing after 22s
2025-11-10 01:56:44 +09:00
koreacomp5
b579b32138 컴잇
Some checks failed
deploy-on-main / deploy (push) Failing after 23s
2025-11-10 01:39:44 +09:00
koreacomp5
4337a8f69a fix
Some checks failed
deploy-on-main / deploy (push) Failing after 22s
2025-11-10 00:04:17 +09:00
koreacomp5
a007ac11ce test
Some checks failed
deploy-on-main / deploy (push) Failing after 22s
2025-11-09 22:05:22 +09:00
koreacomp5
34e831f738 cc
All checks were successful
deploy-on-main / deploy (push) Successful in 28s
2025-11-09 20:02:18 +09:00
koreacomp5
cfbb3d50ee 수정
Some checks failed
deploy-on-main / deploy (push) Failing after 22s
2025-11-09 19:53:42 +09:00
koreacomp5
1c2222da67 적용적용
Some checks failed
deploy-on-main / deploy (push) Failing after 23s
2025-11-08 01:21:44 +09:00
koreacomp5
bb71b892ca 중간
Some checks failed
deploy-on-main / deploy (push) Failing after 21s
2025-11-07 23:41:52 +09:00
koreacomp5
ab81a3da3d ??
Some checks failed
deploy-on-main / deploy (push) Failing after 22s
2025-11-07 21:36:34 +09:00
mota
5f72e6ce7c Merge branch 'imsi'
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-06 11:22:10 +09:00
mota
1ec2df27b0 imsi commit 2025-11-06 11:18:55 +09:00
93 changed files with 4099 additions and 714 deletions

37
.cursor/.prompt/new.md Normal file
View 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
View File

@@ -0,0 +1,3 @@
export { middleware, config } from "./src/middleware";

View File

@@ -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로 통합)

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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 });
return NextResponse.json({ partner }, { status: 201 });
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 });
}
}

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

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

View File

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

View File

@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { nameSchema } from "@/lib/validation/auth";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const nameRaw = searchParams.get("name") ?? "";
const name = nameRaw.trim();
const parsed = nameSchema.safeParse(name);
if (!parsed.success) {
const firstMsg = parsed.error.issues[0]?.message || "잘못된 닉네임 형식";
return NextResponse.json(
{ error: { fieldErrors: { name: [firstMsg] } } },
{ status: 400 }
);
}
const exists = await prisma.user.findFirst({ where: { name } });
return NextResponse.json({ available: !exists });
}

View File

@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { nicknameSchema } from "@/lib/validation/auth";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const nicknameRaw = searchParams.get("nickname") ?? "";
const nickname = nicknameRaw.trim();
const parsed = nicknameSchema.safeParse(nickname);
if (!parsed.success) {
const firstMsg = parsed.error.issues[0]?.message || "잘못된 아이디 형식";
return NextResponse.json(
{ error: { fieldErrors: { nickname: [firstMsg] } } },
{ status: 400 }
);
}
const exists = await prisma.user.findUnique({ where: { nickname } });
return NextResponse.json({ available: !exists });
}

View File

@@ -12,11 +12,18 @@ export async function POST(req: Request) {
const body = await req.json();
const parsed = loginSchema.safeParse(body);
if (!parsed.success)
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const { nickname, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { nickname } });
return NextResponse.json(
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
{ status: 401 }
);
const { id, password } = parsed.data;
// DB에서는 로그인 아이디를 nickname 컬럼으로 보관
const user = await prisma.user.findUnique({ where: { nickname: id } });
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 });
return NextResponse.json(
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
{ status: 401 }
);
}
return NextResponse.json({ user: { userId: user.userId, nickname: user.nickname } });
}

View File

@@ -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({
where: { roleId: { in: roleIds }, allowed: true },
select: { resource: true, action: true },
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 });
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { getUserIdOrAdmin } from "@/lib/auth";
import { verifyPassword, hashPassword } from "@/lib/password";
export async function PUT(req: Request) {
const userId = await getUserIdOrAdmin(req);
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const body = await req.json();
const currentPassword: string | undefined = body?.currentPassword;
const newPassword: string | undefined = body?.newPassword;
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: "currentPassword and newPassword required" }, { status: 400 });
}
if (newPassword.length < 8 || newPassword.length > 100) {
return NextResponse.json({ error: "password length invalid" }, { status: 400 });
}
const user = await prisma.user.findUnique({
where: { userId },
select: { passwordHash: true },
});
if (!user || !user.passwordHash) {
return NextResponse.json({ error: "invalid user" }, { status: 400 });
}
if (!verifyPassword(currentPassword, user.passwordHash)) {
return NextResponse.json({ error: "현재 비밀번호가 올바르지 않습니다" }, { status: 400 });
}
if (verifyPassword(newPassword, user.passwordHash)) {
// 새 비밀번호가 기존과 동일
return NextResponse.json({ error: "새 비밀번호가 기존과 동일합니다" }, { status: 400 });
}
await prisma.user.update({
where: { userId },
data: { passwordHash: hashPassword(newPassword) },
});
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Bad Request" }, { status: 400 });
}
}

View File

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

View File

@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
import { getUserIdFromRequest } from "@/lib/auth";
const sendSchema = z.object({
receiverId: z.string().min(1, "수신자 정보가 없습니다"),
body: z.string().min(1, "메시지를 입력하세요").max(2000, "메시지는 2000자 이하여야 합니다"),
});
export async function POST(req: Request) {
const senderId = getUserIdFromRequest(req);
if (!senderId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const contentType = req.headers.get("content-type") || "";
let data: any = {};
if (contentType.includes("application/json")) {
data = await req.json().catch(() => ({}));
} else if (contentType.includes("application/x-www-form-urlencoded")) {
const form = await req.formData();
data = Object.fromEntries(form.entries());
} else {
// 기본적으로 form 으로 간주
const form = await req.formData().catch(() => null);
if (form) data = Object.fromEntries(form.entries());
}
const parsed = sendSchema.safeParse(data);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const { receiverId, body } = parsed.data;
if (receiverId === senderId) {
return NextResponse.json({ error: "자기 자신에게는 보낼 수 없습니다" }, { status: 400 });
}
const receiver = await prisma.user.findUnique({ where: { userId: receiverId }, select: { userId: true } });
if (!receiver) return NextResponse.json({ error: "수신자를 찾을 수 없습니다" }, { status: 404 });
const message = await prisma.message.create({
data: { senderId, receiverId, body },
select: { id: true, senderId: true, receiverId: true, body: true, createdAt: true },
});
return NextResponse.json({ message }, { status: 201 });
}
const listQuery = z.object({
box: z.enum(["received", "sent"]).default("received").optional(),
page: z.coerce.number().min(1).default(1).optional(),
pageSize: z.coerce.number().min(1).max(100).default(20).optional(),
});
export async function GET(req: Request) {
const userId = getUserIdFromRequest(req);
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(req.url);
const parsed = listQuery.safeParse(Object.fromEntries(searchParams));
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const { box = "received", page = 1, pageSize = 20 } = parsed.data;
const where =
box === "received"
? { receiverId: userId }
: { senderId: userId };
const [total, items] = await Promise.all([
prisma.message.count({ where }),
prisma.message.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
body: true,
createdAt: true,
sender: { select: { userId: true, nickname: true } },
receiver: { select: { userId: true, nickname: true } },
},
}),
]);
return NextResponse.json({ total, page, pageSize, items });
}

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,15 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { getUserIdFromRequest } from "@/lib/auth";
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
export async function GET(req: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
const requesterId = getUserIdFromRequest(req);
const post = await prisma.post.findUnique({
where: { id },
select: { authorId: true },
});
const postAuthorId = post?.authorId ?? null;
// 최상위 댓글만 가져오기
const topComments = await prisma.comment.findMany({
@@ -29,23 +36,33 @@ export async function GET(_: Request, context: { params: Promise<{ id: string }>
});
// 재귀적으로 댓글 구조 변환
const transformComment = (comment: any) => ({
id: comment.id,
parentId: comment.parentId,
depth: comment.depth,
content: comment.isSecret ? "비밀댓글입니다." : comment.content,
isAnonymous: comment.isAnonymous,
isSecret: comment.isSecret,
author: comment.author ? {
userId: comment.author.userId,
nickname: comment.author.nickname,
profileImage: comment.author.profileImage,
} : null,
anonId: comment.isAnonymous ? comment.id.slice(-6) : undefined,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
replies: comment.replies ? comment.replies.map(transformComment) : [],
});
const transformComment = (comment: any): any => {
const commentAuthorId: string | null = comment.author?.userId ?? null;
const canViewSecret =
!comment.isSecret ||
(requesterId != null &&
(requesterId === commentAuthorId || requesterId === postAuthorId));
return {
id: comment.id,
parentId: comment.parentId,
depth: comment.depth,
content: canViewSecret ? comment.content : "비밀댓글입니다.",
isAnonymous: comment.isAnonymous,
isSecret: comment.isSecret,
author: comment.author
? {
userId: comment.author.userId,
nickname: comment.author.nickname,
profileImage: comment.author.profileImage,
}
: null,
anonId: comment.isAnonymous ? comment.id.slice(-6) : undefined,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
replies: comment.replies ? comment.replies.map(transformComment) : [],
};
};
const presented = topComments.map(transformComment);
return NextResponse.json({ comments: presented });

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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}` : ""}`}
/>
)}

View File

@@ -2,7 +2,7 @@
// 클라이언트 훅(useState/useEffect)을 사용하여 세션 표시/로그아웃을 처리합니다.
import Image from "next/image";
import Link from "next/link";
import { SearchBar } from "@/app/components/SearchBar";
import { SearchBar } from "./SearchBar";
import useSWR from "swr";
import { UserAvatar } from "@/app/components/UserAvatar";
import { GradeIcon } from "@/app/components/GradeIcon";
@@ -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/>
<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="어드민(임시)"
>
()
</Link>
{authData?.user ? (
<>
{/* 인사 + 로그아웃을 하나의 배지로 묶어 자연스럽게 표시 */}
<div className="inline-flex items-center h-10 rounded-md border border-neutral-300 bg-white overflow-hidden">
<Link
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 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>
))}

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

View File

@@ -19,7 +19,7 @@ export function AutoLoginAdmin() {
fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nickname: "admin", password: "1234" }),
body: JSON.stringify({ id: "admin", password: "1234" }),
})
.then((res) => res.json())
.then((loginData) => {

View File

@@ -1,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">
<UserAvatar
src={user.profileImage}
alt={user.nickname || "프로필"}
width={90}
height={72}
className="w-full h-full object-cover rounded-none"
/>
<div className="absolute top-0 right-0 w-[20px] h-[20px] flex items-center justify-center">
<GradeIcon grade={user.grade} width={20} height={20} />
<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={56}
height={56}
className="w-full h-full object-cover rounded-full"
/>
</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">

View File

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

View File

@@ -1,7 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { memo, useEffect, useRef, useState } from "react";
import { UserAvatar } from "./UserAvatar";
import Link from "next/link";
import { UserNameMenu } from "./UserNameMenu";
type CommentAuthor = {
userId: string;
@@ -27,6 +28,192 @@ type Props = {
postId: string;
};
type CommentItemProps = {
comment: Comment;
depth?: number;
expandedReplies: Set<string>;
toggleReplies: (commentId: string) => void;
replyingTo: string | null;
setReplyingTo: (id: string | null) => void;
replyContents: Record<string, string>;
setReplyContents: React.Dispatch<React.SetStateAction<Record<string, string>>>;
handleSubmitReply: (parentId: string | null) => void;
replySecretFlags: Record<string, boolean>;
setReplySecretFlags: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
};
function formatDate(dateString: string) {
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "방금 전";
if (minutes < 60) return `${minutes}분 전`;
if (hours < 24) return `${hours}시간 전`;
if (days < 7) return `${days}일 전`;
return date.toLocaleDateString();
}
// 개별 댓글 아이템: 부모 렌더 시 타입이 바뀌어 리마운트되지 않도록 파일 최상위에 선언하고 memo 처리
const CommentItem = memo(function CommentItem({
comment,
depth = 0,
expandedReplies,
toggleReplies,
replyingTo,
setReplyingTo,
replyContents,
setReplyContents,
handleSubmitReply,
replySecretFlags,
setReplySecretFlags,
}: CommentItemProps) {
const canReply = depth < 2; // 최대 3단계까지만
const hasReplies = comment.replies && comment.replies.length > 0;
const isExpanded = expandedReplies.has(comment.id);
const isReplyingHere = replyingTo === comment.id;
const replyRef = useRef<HTMLTextAreaElement | null>(null);
// 답글 폼이 열릴 때만 포커스를 보장
useEffect(() => {
if (isReplyingHere) {
replyRef.current?.focus();
}
}, [isReplyingHere]);
return (
<div className={`${depth > 0 ? "ml-8 border-l-2 border-neutral-200 pl-4" : ""}`}>
<div className="flex gap-3 py-3">
<UserAvatar
src={comment.author?.profileImage}
alt={comment.author?.nickname || "익명"}
width={40}
height={40}
className="rounded-full shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold text-neutral-900">
{/* 닉네임 드롭다운 */}
{/* 익명 댓글이면 드롭다운 없이 표시 */}
{comment.isAnonymous ? (
`익명${comment.anonId}`
) : (
<UserNameMenu userId={comment.author?.userId} nickname={comment.author?.nickname} />
)}
</span>
<span className="text-xs text-neutral-500">{formatDate(comment.createdAt)}</span>
{comment.updatedAt !== comment.createdAt && (
<span className="text-xs text-neutral-400">()</span>
)}
</div>
<div className="text-sm text-neutral-700 mb-2 whitespace-pre-wrap break-words">
{comment.content}
</div>
<div className="flex items-center gap-3">
{canReply && (
<button
onClick={() => {
setReplyingTo(comment.id);
setReplyContents((prev) => ({ ...prev, [comment.id]: prev[comment.id] ?? "" }));
}}
className="text-xs text-neutral-500 hover:text-neutral-900"
>
</button>
)}
{hasReplies && (
<button
onClick={() => toggleReplies(comment.id)}
className="text-xs text-neutral-500 hover:text-neutral-900"
>
{isExpanded ? "답글 숨기기" : `답글 ${comment.replies.length}`}
</button>
)}
</div>
{/* 답글 입력 폼 */}
{isReplyingHere && (
<div className="mt-3 p-3 bg-neutral-50 rounded-lg">
<textarea
ref={replyRef}
value={replyContents[comment.id] ?? ""}
onChange={(e) => setReplyContents((prev) => ({ ...prev, [comment.id]: e.target.value }))}
placeholder="답글을 입력하세요..."
className="w-full p-2 border border-neutral-300 rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-neutral-400"
rows={3}
/>
<div className="flex items-center justify-between mt-2">
<label className="flex items-center gap-2 text-xs text-neutral-600">
<input
type="checkbox"
checked={!!replySecretFlags[comment.id]}
onChange={(e) =>
setReplySecretFlags((prev) => ({ ...prev, [comment.id]: e.target.checked }))
}
/>
</label>
<div className="flex gap-2">
<button
onClick={() => {
setReplyingTo(null);
setReplyContents((prev) => {
const next = { ...prev };
delete next[comment.id];
return next;
});
setReplySecretFlags((prev) => {
const next = { ...prev };
delete next[comment.id];
return next;
});
}}
className="px-3 py-1 text-xs border border-neutral-300 rounded-md hover:bg-neutral-100"
>
</button>
<button
onClick={() => handleSubmitReply(comment.id)}
className="px-3 py-1 text-xs bg-neutral-900 text-white rounded-md hover:bg-neutral-800"
>
</button>
</div>
</div>
</div>
)}
</div>
</div>
{/* 대댓글 목록 */}
{hasReplies && isExpanded && (
<div className="mt-2">
{comment.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
depth={depth + 1}
expandedReplies={expandedReplies}
toggleReplies={toggleReplies}
replyingTo={replyingTo}
setReplyingTo={setReplyingTo}
replyContents={replyContents}
setReplyContents={setReplyContents}
handleSubmitReply={handleSubmitReply}
replySecretFlags={replySecretFlags}
setReplySecretFlags={setReplySecretFlags}
/>
))}
</div>
)}
</div>
);
});
export function CommentSection({ postId }: Props) {
const [comments, setComments] = useState<Comment[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -34,6 +221,8 @@ export function CommentSection({ postId }: Props) {
const [newContent, setNewContent] = useState("");
const [replyContents, setReplyContents] = useState<Record<string, string>>({});
const [expandedReplies, setExpandedReplies] = useState<Set<string>>(new Set());
const [newIsSecret, setNewIsSecret] = useState(false);
const [replySecretFlags, setReplySecretFlags] = useState<Record<string, boolean>>({});
useEffect(() => {
loadComments();
@@ -64,6 +253,7 @@ export function CommentSection({ postId }: Props) {
postId,
parentId,
content,
isSecret: parentId ? !!replySecretFlags[parentId] : newIsSecret,
}),
});
@@ -79,8 +269,14 @@ export function CommentSection({ postId }: Props) {
delete next[parentId!];
return next;
});
setReplySecretFlags((prev) => {
const next = { ...prev };
delete next[parentId!];
return next;
});
} else {
setNewContent("");
setNewIsSecret(false);
}
setReplyingTo(null);
loadComments();
@@ -100,119 +296,6 @@ export function CommentSection({ postId }: Props) {
setExpandedReplies(newExpanded);
}
function formatDate(dateString: string) {
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "방금 전";
if (minutes < 60) return `${minutes}분 전`;
if (hours < 24) return `${hours}시간 전`;
if (days < 7) return `${days}일 전`;
return date.toLocaleDateString();
}
function CommentItem({ comment, depth = 0 }: { comment: Comment; depth?: number }) {
const canReply = depth < 2; // 최대 3단계까지만
const hasReplies = comment.replies && comment.replies.length > 0;
const isExpanded = expandedReplies.has(comment.id);
return (
<div className={`${depth > 0 ? "ml-8 border-l-2 border-neutral-200 pl-4" : ""}`}>
<div className="flex gap-3 py-3">
<UserAvatar
src={comment.author?.profileImage}
alt={comment.author?.nickname || "익명"}
width={40}
height={40}
className="rounded-full shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold text-neutral-900">
{comment.isAnonymous ? `익명${comment.anonId}` : comment.author?.nickname || "익명"}
</span>
<span className="text-xs text-neutral-500">{formatDate(comment.createdAt)}</span>
{comment.updatedAt !== comment.createdAt && (
<span className="text-xs text-neutral-400">()</span>
)}
</div>
<div className="text-sm text-neutral-700 mb-2 whitespace-pre-wrap break-words">
{comment.content}
</div>
<div className="flex items-center gap-3">
{canReply && (
<button
onClick={() => {
setReplyingTo(comment.id);
setReplyContents((prev) => ({ ...prev, [comment.id]: prev[comment.id] ?? "" }));
}}
className="text-xs text-neutral-500 hover:text-neutral-900"
>
</button>
)}
{hasReplies && (
<button
onClick={() => toggleReplies(comment.id)}
className="text-xs text-neutral-500 hover:text-neutral-900"
>
{isExpanded ? "답글 숨기기" : `답글 ${comment.replies.length}`}
</button>
)}
</div>
{/* 답글 입력 폼 */}
{replyingTo === comment.id && (
<div className="mt-3 p-3 bg-neutral-50 rounded-lg">
<textarea
value={replyContents[comment.id] ?? ""}
onChange={(e) => setReplyContents((prev) => ({ ...prev, [comment.id]: e.target.value }))}
placeholder="답글을 입력하세요..."
className="w-full p-2 border border-neutral-300 rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-neutral-400"
rows={3}
/>
<div className="flex gap-2 mt-2 justify-end">
<button
onClick={() => {
setReplyingTo(null);
setReplyContents((prev) => {
const next = { ...prev };
delete next[comment.id];
return next;
});
}}
className="px-3 py-1 text-xs border border-neutral-300 rounded-md hover:bg-neutral-100"
>
</button>
<button
onClick={() => handleSubmitReply(comment.id)}
className="px-3 py-1 text-xs bg-neutral-900 text-white rounded-md hover:bg-neutral-800"
>
</button>
</div>
</div>
)}
</div>
</div>
{/* 대댓글 목록 */}
{hasReplies && isExpanded && (
<div className="mt-2">
{comment.replies.map((reply) => (
<CommentItem key={reply.id} comment={reply} depth={depth + 1} />
))}
</div>
)}
</div>
);
}
if (isLoading) {
return (
<section className="rounded-xl overflow-hidden bg-white p-4">
@@ -237,7 +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>
)}

View File

@@ -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="코드">&lt;/&gt;</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,

View File

@@ -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]">
{subItems.map((s) => (
<Link
key={s.id}
href={s.href}
<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={
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"
(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"
}
>
{s.name}
</Link>
{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-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]">
{subItems.map((s) => (
<Link
key={s.id}
href={s.href}
<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={
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"
(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"
}
>
{s.name}
</Link>
{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-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>

View File

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

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

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

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

View File

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

View File

@@ -0,0 +1,171 @@
"use client";
import React from "react";
import { Modal } from "@/app/components/ui/Modal";
import { ProfileImageEditor } from "@/app/components/ProfileImageEditor";
import { useToast } from "@/app/components/ui/ToastProvider";
import { UserAvatar } from "@/app/components/UserAvatar";
type Props = {
open: boolean;
onClose: () => void;
initialProfileImageUrl?: string | null;
};
export function ProfileEditModal({ open, onClose, initialProfileImageUrl }: Props) {
const { show } = useToast();
const [previewUrl, setPreviewUrl] = React.useState<string | null>(initialProfileImageUrl || null);
const [currentPassword, setCurrentPassword] = React.useState("");
const [newPassword, setNewPassword] = React.useState("");
const [confirmPassword, setConfirmPassword] = React.useState("");
const [saving, setSaving] = React.useState(false);
async function changePassword(e: React.FormEvent) {
e.preventDefault();
if (saving) return;
if (!currentPassword || !newPassword || !confirmPassword) {
show("모든 비밀번호 입력란을 채워주세요");
return;
}
if (newPassword.length < 8 || newPassword.length > 100) {
show("새 비밀번호는 8~100자여야 합니다");
return;
}
if (newPassword !== confirmPassword) {
show("새 비밀번호와 확인이 일치하지 않습니다");
return;
}
try {
setSaving(true);
const res = await fetch("/api/me/password", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
currentPassword,
newPassword,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.error || "비밀번호 변경에 실패했습니다");
}
show("비밀번호가 변경되었습니다");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch (e: any) {
show(e?.message || "비밀번호 변경에 실패했습니다");
} finally {
setSaving(false);
}
}
return (
<Modal open={open} onClose={onClose}>
<div className="w-[720px] max-w-[92vw]">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold"> </h2>
<button
type="button"
onClick={onClose}
className="px-2 py-1 text-sm text-neutral-500 hover:text-neutral-800"
aria-label="닫기"
>
</button>
</div>
<div className="space-y-8">
<section>
<h3 className="text-sm font-semibold text-neutral-700 mb-3"> </h3>
<div className="rounded-md border border-neutral-200 p-3">
{/* 원형 미리보기 */}
<div className="flex items-center justify-center mb-3">
<div className="relative w-[112px] h-[112px]">
<div className="absolute inset-0 flex items-center justify-center">
<svg className="w-full h-full" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="80" cy="80" r="79" stroke="#8c8c8c" strokeWidth="2" fill="none" />
</svg>
</div>
<div className="absolute inset-[8px] flex items-center justify-center">
<UserAvatar
src={previewUrl || null}
alt="프로필 미리보기"
width={140}
height={140}
className="rounded-full w-full h-full object-cover"
/>
</div>
</div>
</div>
{/* 업로드/제거 컨트롤 (경로 텍스트 숨김) */}
<ProfileImageEditor
initialUrl={initialProfileImageUrl || null}
onUrlChange={(u) => setPreviewUrl(u || null)}
hideUrlText
/>
</div>
</section>
<section>
<h3 className="text-sm font-semibold text-neutral-700 mb-3"> </h3>
<form onSubmit={changePassword} className="rounded-md border border-neutral-200 p-3 space-y-3">
<div className="space-y-1">
<label className="block text-xs font-medium text-neutral-600"> </label>
<input
type="password"
className="w-full border border-neutral-300 rounded-md px-3 py-2 text-sm"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="현재 비밀번호"
autoComplete="current-password"
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-medium text-neutral-600"> </label>
<input
type="password"
className="w-full border border-neutral-300 rounded-md px-3 py-2 text-sm"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="새 비밀번호 (8~100자)"
autoComplete="new-password"
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-medium text-neutral-600"> </label>
<input
type="password"
className="w-full border border-neutral-300 rounded-md px-3 py-2 text-sm"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="새 비밀번호 확인"
autoComplete="new-password"
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="h-9 px-3 rounded-md border border-neutral-300 text-sm text-neutral-700 hover:bg-neutral-100"
>
</button>
<button
type="submit"
disabled={saving}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800 disabled:opacity-60"
>
{saving ? "처리 중..." : "비밀번호 변경"}
</button>
</div>
</form>
</section>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import React from "react";
import { ProfileEditModal } from "@/app/components/ProfileEditModal";
type Props = {
initialProfileImageUrl?: string | null;
};
export function ProfileEditTrigger({ initialProfileImageUrl }: Props) {
const [open, setOpen] = React.useState(false);
return (
<>
<button
className="w-[20px] h-[20px] shrink-0"
type="button"
onClick={() => setOpen(true)}
aria-label="프로필 수정"
title="프로필 수정"
>
<svg width="20" height="20" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M1.05882 10.9412H1.94929L9.17506 3.71541L8.28459 2.82494L1.05882 10.0507V10.9412ZM0.638118 12C0.457294 12 0.305765 11.9388 0.183529 11.8165C0.0611764 11.6942 0 11.5427 0 11.3619V10.1389C0 9.96682 0.0330587 9.80277 0.0991764 9.64677C0.165176 9.49077 0.256118 9.35483 0.372 9.23894L9.31094 0.304058C9.41765 0.207117 9.53547 0.132235 9.66441 0.0794118C9.79347 0.0264706 9.92877 0 10.0703 0C10.2118 0 10.3489 0.025118 10.4815 0.0753533C10.6142 0.125589 10.7316 0.20547 10.8339 0.315L11.6959 1.18782C11.8055 1.29006 11.8835 1.40771 11.9301 1.54076C11.9767 1.67382 12 1.80688 12 1.93994C12 2.08194 11.9758 2.21741 11.9273 2.34635C11.8788 2.47541 11.8017 2.59329 11.6959 2.7L2.76106 11.628C2.64518 11.7439 2.50924 11.8348 2.35324 11.9008C2.19724 11.9669 2.03318 12 1.86106 12H0.638118ZM8.72206 3.27794L8.28459 2.82494L9.17506 3.71541L8.72206 3.27794Z" fill="#707070"/>
</svg>
</button>
<ProfileEditModal
open={open}
onClose={() => setOpen(false)}
initialProfileImageUrl={initialProfileImageUrl || null}
/>
</>
);
}

View File

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

View File

@@ -2,7 +2,7 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
export function SearchBar() {
export function SearchBar({ fullWidth = false, className = "" }: { fullWidth?: boolean; className?: string }) {
const router = useRouter();
const [term, setTerm] = useState("");
return (
@@ -14,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>
);

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

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

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

View File

@@ -0,0 +1,94 @@
"use client";
import React from "react";
import Link from "next/link";
import { useMessageModal } from "@/app/components/ui/MessageModalProvider";
type Props = {
userId?: string | null;
nickname?: string | null;
isAnonymous?: boolean;
className?: string;
underlineOnHover?: boolean;
};
export function UserNameMenu({
userId,
nickname,
isAnonymous,
className,
underlineOnHover = true,
}: Props) {
const { openMessageModal } = useMessageModal();
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (!ref.current) return;
if (!ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
function onEsc(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onClickOutside);
document.addEventListener("keydown", onEsc);
return () => {
document.removeEventListener("mousedown", onClickOutside);
document.removeEventListener("keydown", onEsc);
};
}, []);
const label = isAnonymous ? "익명" : (nickname ?? "익명");
const canInteract = !!userId && !isAnonymous;
if (!canInteract) {
return (
<span className={className}>{label}</span>
);
}
return (
<div ref={ref} className={`relative inline-block ${className || ""}`}>
<button
type="button"
className={`text-left ${underlineOnHover ? "hover:underline" : ""} text-inherit cursor-pointer`}
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
>
{label}
</button>
{open && (
<div
role="menu"
className="absolute z-50 mt-1 w-36 rounded-md border border-neutral-200 bg-white shadow-md overflow-hidden"
>
<Link
href={`/users/${encodeURIComponent(userId!)}`}
className="block px-3 py-2 text-sm text-neutral-800 hover:bg-neutral-50"
role="menuitem"
onClick={() => setOpen(false)}
>
</Link>
<button
type="button"
className="w-full text-left block px-3 py-2 text-sm text-neutral-800 hover:bg-neutral-50 cursor-pointer"
role="menuitem"
onClick={() => {
openMessageModal({ receiverId: userId!, receiverNickname: nickname ?? null });
setOpen(false);
}}
>
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
import { SendMessageForm } from "@/app/components/SendMessageForm";
type MessageTarget = { receiverId: string; receiverNickname?: string | null } | null;
type Ctx = { openMessageModal: (target: { receiverId: string; receiverNickname?: string | null }) => void };
const MessageModalCtx = createContext<Ctx>({ openMessageModal: () => {} });
export function useMessageModal() {
return useContext(MessageModalCtx);
}
export function MessageModalProvider({ children }: { children: React.ReactNode }) {
const [target, setTarget] = useState<MessageTarget>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const openMessageModal = (t: { receiverId: string; receiverNickname?: string | null }) => {
setTarget({ receiverId: t.receiverId, receiverNickname: t.receiverNickname ?? null });
};
const close = () => setTarget(null);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
return (
<MessageModalCtx.Provider value={{ openMessageModal }}>
{children}
{target && (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center"
role="dialog"
aria-modal="true"
>
<div
className="absolute inset-0 bg-black/40"
onClick={close}
aria-hidden
/>
<div
ref={dialogRef}
className="relative z-[1001] w-[92%] max-w-[520px] rounded-lg bg-white shadow-xl border border-neutral-200"
>
<div className="px-5 py-3 border-b border-neutral-200 flex items-center justify-between">
<h3 className="text-base font-semibold text-neutral-900"> </h3>
<button
onClick={close}
className="h-8 w-8 inline-flex items-center justify-center rounded-md hover:bg-neutral-100 cursor-pointer"
aria-label="닫기"
>
×
</button>
</div>
<div className="p-5">
<SendMessageForm receiverId={target.receiverId} receiverNickname={target.receiverNickname} 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>
);
}

View File

@@ -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,26 +23,27 @@ export default function RootLayout({
<body className="min-h-screen bg-background text-foreground antialiased">
<QueryProvider>
<ToastProvider>
<AutoLoginAdmin />
<div className="min-h-screen flex flex-col">
<div className="sticky top-0 z-50 bg-white/80 backdrop-blur">
<div className="mx-auto w-full">
<Suspense fallback={null}>
<AppHeader />
</Suspense>
<MessageModalProvider>
<div className="min-h-screen flex flex-col">
<div className="sticky top-0 z-50 bg-white/90 backdrop-blur">
<div className="mx-auto w-full">
<Suspense fallback={null}>
<AppHeader />
</Suspense>
</div>
</div>
<main className="flex-1 bg-[#F2F2F2]">
<div className="max-w-[1500px] mx-auto px-4 py-6">
{children}
</div>
</main>
<div className="">
<div className="max-w-[1920px] mx-auto px-4">
<AppFooter />
</div>
</div>
</div>
<main className="flex-1 bg-[#F2F2F2]">
<div className="max-w-[1920px] mx-auto px-4 py-6">
{children}
</div>
</main>
<div className="">
<div className="max-w-[1920px] mx-auto px-4">
<AppFooter />
</div>
</div>
</div>
</MessageModalProvider>
</ToastProvider>
</QueryProvider>
</body>

View File

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

View File

@@ -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" && (
<div className="bg-white rounded-[24px] p-4">
<div className="text-center text-neutral-500 py-10"> .</div>
</div>
)}
{activeTab === "messages-sent" && (
<div className="bg-white rounded-[24px] p-4">
<div className="text-center text-neutral-500 py-10"> .</div>
</div>
)}
{activeTab === "messages-received" && (async () => {
const pageSize = 20;
// 받은 쪽지함 진입 시 미읽은 메시지를 읽음 처리
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">
<ul className="divide-y divide-neutral-200">
{messages.map((m) => (
<li key={m.id} className="py-3 flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="text-sm text-neutral-800">
<Link href={`/users/${m.sender.userId}`} className="font-semibold hover:underline">{m.sender.nickname || "알 수 없음"}</Link>
<span className="text-xs text-neutral-500 ml-2">{new Date(m.createdAt).toLocaleString()}</span>
</div>
<div className="text-sm text-neutral-700 mt-1 whitespace-pre-wrap break-words">{m.body}</div>
</div>
</li>
))}
</ul>
</div>
);
})()}
{activeTab === "messages-sent" && (async () => {
const pageSize = 20;
const receiver = toUserId ? await prisma.user.findUnique({ where: { userId: toUserId }, select: { userId: true, nickname: true } }) : null;
const messages = await prisma.message.findMany({
where: { senderId: currentUser.userId },
orderBy: { createdAt: "desc" },
take: pageSize,
skip: (page - 1) * pageSize,
select: { id: true, body: true, createdAt: true, receiver: { select: { userId: true, nickname: true } } },
});
return (
<div className="bg-white rounded-[24px] p-4 space-y-6">
{receiver ? (
<div className="border border-neutral-200 rounded-md p-3">
<SendMessageForm receiverId={receiver.userId} receiverNickname={receiver.nickname} />
</div>
) : (
<div className="text-sm text-neutral-500"> .</div>
)}
<div>
<h3 className="text-sm font-semibold text-neutral-900 mb-2"> </h3>
<ul className="divide-y divide-neutral-200">
{messages.map((m) => (
<li key={m.id} className="py-3 flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="text-sm text-neutral-800">
<Link href={`/users/${m.receiver.userId}`} className="font-semibold hover:underline">{m.receiver.nickname || "알 수 없음"}</Link>
<span className="text-xs text-neutral-500 ml-2">{new Date(m.createdAt).toLocaleString()}</span>
</div>
<div className="text-sm text-neutral-700 mt-1 whitespace-pre-wrap break-words">{m.body}</div>
</div>
</li>
))}
</ul>
</div>
</div>
);
})()}
</section>
</div>
);

View File

@@ -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,84 +233,92 @@ 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" />
<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 || "프로필"}
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} />
{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 || "프로필"}
width={120}
height={120}
className="rounded-full"
/>
<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="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}</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)}</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.toLocaleString()}</div>
</div>
</div>
</div>
<div className="flex flex-col gap-[12px] relative z-10">
<Link href="/my-page" 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>
</Link>
<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 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]">
<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 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">{myPostsCount.toLocaleString()}</span>
</span>
</Link>
<Link href={`/my-page?tab=comments`} 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 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">{myCommentsCount.toLocaleString()}</span>
</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>
<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="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>
<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>
<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>
</div>
</div>
<div className="flex flex-col gap-[12px] relative z-10">
<Link href="/my-page" 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>
</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]">
<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>
</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]">
<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 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">{myPostsCount.toLocaleString()}</span>
</span>
</Link>
<Link href={`/my-page?tab=comments`} 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 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">{myCommentsCount.toLocaleString()}</span>
</span>
</Link>
</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">

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState, useRef } from "react";
import Link from "next/link";
import { UserNameMenu } from "@/app/components/UserNameMenu";
import { useSearchParams, useRouter } from "next/navigation";
function stripHtml(html: string | null | undefined): string {
@@ -170,7 +171,9 @@ export function RelatedPosts({ boardId, boardName, currentPostId, pageSize = 10
</div>
</div>
<div className="md:w-[120px] text-xs text-neutral-600 text-center hidden md:flex items-center justify-center gap-2">
<span className="truncate max-w-[84px]">{p.author?.nickname ?? "익명"}</span>
<span className="truncate max-w-[84px]">
<UserNameMenu userId={(p as any).author?.userId} nickname={p.author?.nickname} />
</span>
</div>
<div className="md:w-[120px] text-[11px] text-[#8c8c8c] text-center flex items-center justify-center gap-3">
<span> {p.stat?.views ?? 0}</span>
@@ -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>

View File

@@ -0,0 +1,25 @@
"use client";
import { useEffect, useRef } from "react";
export function ViewTracker({ postId }: { postId: string }) {
const initializedRef = useRef(false);
useEffect(() => {
if (initializedRef.current) return;
initializedRef.current = true;
// 조회수 증가 (중복 방지: CSR 마운트 1회만)
try {
fetch(`/api/posts/${postId}/view`, {
method: "POST",
keepalive: true,
}).catch(() => {});
} catch {
// ignore
}
}, [postId]);
return null;
}

View File

@@ -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 && (
<RelatedPosts
boardId={post.boardId}
boardName={post.board.name}
currentPostId={id}
pageSize={10}
/>
<section className="px-[0px] md:px-[30px]">
<RelatedPosts
boardId={post.boardId}
boardName={post.board.name}
currentPostId={id}
pageSize={10}
/>
</section>
)}
</div>
);

View File

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

View File

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

View File

@@ -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[]>;
if (fieldErrors.nickname?.length || fieldErrors.name?.length) {
setErrors((errs) => ({ ...errs, ...fieldErrors }));
const msg = fieldErrors.nickname?.[0] || fieldErrors.name?.[0] || "회원가입 실패";
throw new Error(msg);
} else {
setErrors((errs) => ({ ...errs, nickname: ["이미 사용 중인 아이디입니다"] }));
throw new Error("이미 사용 중인 아이디입니다");
}
}
const fieldErrors = (data?.error?.fieldErrors ?? {}) as Record<string, string[]>;
setErrors(fieldErrors);
if (Object.keys(fieldErrors).length) setErrors(fieldErrors);
const msg = data?.error?.message || Object.values(fieldErrors)[0]?.[0] || "회원가입 실패";
throw new Error(msg);
}
setErrors({});
show("회원가입 성공! 로그인해주세요");
location.href = "/login";
show("회원가입이 완료되었습니다");
setTimeout(() => {
location.href = "/login";
}, 1200);
} catch (err: any) {
show(err.message || "회원가입 실패");
} finally {
@@ -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}
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input name="agreeTerms" type="checkbox" checked={form.agreeTerms} onChange={onChange} />
</label>
<Button type="submit" disabled={loading}>{loading ? "가입 중..." : "가입하기"}</Button>
<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
type="checkbox"
name="agreeTerms"
checked={form.agreeTerms}
onChange={onChange}
/>
<span style={{ fontSize: 14, color: "#333" }}> </span>
</label>
{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
View 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>
);
}

View 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

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

View File

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

View File

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

View File

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

View File

@@ -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)$).*)"],
};