Compare commits

..

60 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
koreacomp5
1fb859fdf9 메인화면 게시글 뷰 컨테이너 라디우스
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-06 00:04:40 +09:00
koreacomp5
e91085b4ed 메인 게시판뷰 디자인 디테일 2025-11-06 00:02:13 +09:00
koreacomp5
2d722a89c7 시드 또수정
All checks were successful
deploy-on-main / deploy (push) Successful in 28s
2025-11-05 23:50:01 +09:00
koreacomp5
c348fb55fa seed update
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-05 23:47:44 +09:00
koreacomp5
c7d5679788 사이드바 수정
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-05 23:45:13 +09:00
koreacomp5
a646dfd09f 메인화면 모바일 등등
All checks were successful
deploy-on-main / deploy (push) Successful in 28s
2025-11-05 23:38:35 +09:00
koreacomp5
4d1e7343c3 Merge branch 'homework'
All checks were successful
deploy-on-main / deploy (push) Successful in 35s
2025-11-05 23:05:38 +09:00
koreacomp5
808fe5fc68 header 2025-11-05 23:03:53 +09:00
koreacomp5
7d9c241d17 메인페이지 더수정 homework 2025-11-05 22:49:25 +09:00
koreacomp5
5b8749d11f 메인스타일, 호버 등 2025-11-05 22:27:29 +09:00
koreacomp5
e518c988b2 메인페이지 게시판뷰 바로가기 버튼 2025-11-05 21:17:14 +09:00
koreacomp5
12044587c9 homework1 2025-11-05 21:07:49 +09:00
ecf2dab35c last check
All checks were successful
deploy-on-main / deploy (push) Successful in 28s
2025-11-04 15:34:58 +00:00
1016d8717c deploytest002
All checks were successful
deploy-on-main / deploy (push) Successful in 40s
2025-11-04 15:33:52 +00:00
e7dd2cca00 deploytest 001
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:31:31 +00:00
34f9f0cb32 deploy.yml
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:25:23 +00:00
30c66158d5 123
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:22:52 +00:00
69c54fad1d deploytest2
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:20:26 +00:00
c20599164e deploy test
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:17:37 +00:00
664c72fa41 runnerfile commit
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:12:04 +00:00
486a18451b new address check2
Some checks failed
deploy-on-main / deploy (push) Failing after 8s
2025-11-04 14:48:49 +00:00
koreacomp5
9bfbafcec1 new address check
Some checks failed
deploy-on-main / deploy (push) Has been cancelled
2025-11-04 23:48:13 +09:00
mota
2c9898be90 test
Some checks failed
deploy-on-main / deploy (push) Has been cancelled
2025-11-04 17:28:17 +09:00
34b2739405 deploy fix
Some checks failed
deploy-on-main / deploy (push) Has been cancelled
2025-11-04 08:27:47 +00:00
4aaa542ded build error fix
Some checks failed
deploy-on-main / deploy (push) Has been cancelled
2025-11-04 08:20:03 +00:00
1049d029b4 deploysetting
Some checks failed
deploy-on-main / deploy (push) Has been cancelled
2025-11-04 08:01:05 +00:00
koreacomp5
f4e46c39fb appheader 2025-11-03 10:22:44 +09:00
mota
fadd402e63 123 2025-11-02 15:13:03 +09:00
mota
b10d41532b 123 2025-11-02 13:32:19 +09:00
mota
2047e044d5 Merge branch 'subwork' 2025-11-02 12:08:00 +09:00
mota
2c86f2d515 메인페이지 2025-11-02 12:07:11 +09:00
mota
870da3838a 네브바 2025-11-02 12:06:55 +09:00
mota
11726858cc Merge branch 'subwork' 2025-11-02 11:35:54 +09:00
mota
1ced95f218 main 2025-11-02 11:34:47 +09:00
mota
9e02aa3a88 글쓰기 2025-11-02 11:33:44 +09:00
mota
9ff08d3e58 dd 2025-11-02 11:01:18 +09:00
koreacomp5
e0aacab1d1 Merge branch 'mainwork' 2025-11-02 07:02:27 +09:00
koreacomp5
58af463585 ㅇㅇ 2025-11-02 07:01:42 +09:00
koreacomp5
d54ad82095 Merge branch 'subwork' into mainwork 2025-11-02 04:59:21 +09:00
koreacomp5
9c28d50890 main 2025-11-02 04:59:09 +09:00
koreacomp5
0bf270d884 sub 2025-11-02 04:59:04 +09:00
koreacomp5
c6e60cd34d Merge branch 'mainwork' into subwork 2025-11-02 04:40:04 +09:00
157 changed files with 6848 additions and 606 deletions

View File

@@ -1,17 +1,18 @@
배너 디테일
카드 디테일
메인 디테일 프리뷰, 글, 스페셜_랭크
기본 리스트 , 글이 없습니다.
글쓰기
글뷰, 댓글 +리스트
x 메인 게시판 일반
메인 게시판 프리뷰
x 메인 게시판 스페셜랭크
기본 리스트
스페셜_랭크
스페셜_출석
스페셜_제휴업체
스페셜_제휴업체지도
게시글 뷰 + 댓글
로그인관련
회원가입 페이지
회원쪽지
링크로들어오면 보이고 거기서 페이지 이동하면 안보이게

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.이미지 사이즈 미리불러오기

View File

@@ -0,0 +1,36 @@
name: deploy-on-main
on:
push:
branches: [ "main" ]
jobs:
deploy:
runs-on: [ self-hosted, linux_amd64 ]
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Node / PM2 / Prisma 확인
run: |
node -v
npm -v
pm2 -v || true
npx prisma --version || true
- name: 배포
env:
APP_DIR: /root/msgapp
run: |
set -e
cd "$APP_DIR"
# 최신 main 코드 반영
git fetch origin main
git reset --hard origin/main
npm ci
npm run dbforce
npm run build
pm2 reload ecosystem.config.js --env production || (pm2 start ecosystem.config.js --env production && pm2 save)
pm2 list

1
.gitignore vendored
View File

@@ -46,3 +46,4 @@ next-env.d.ts
/prisma/prisma/dev.db
*.ignore
/logs

13
.runner Normal file
View File

@@ -0,0 +1,13 @@
{
"WARNING": "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.",
"id": 2,
"uuid": "7dc76cf1-fcf2-48ba-a97a-73fb5d661049",
"name": "msgapp",
"token": "f4391f32ea51657c4366698db1b8333b5d5e1b83",
"address": "https://www.plubu.com/",
"labels": [
"linux_amd64:host",
"self-hosted:host"
],
"ephemeral": false
}

6
deploytest.txt Normal file
View File

@@ -0,0 +1,6 @@
test
new address check
new address check2
deploy test2dfdf213
deploy test001
deploy test002

18
ecosystem.config.js Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
apps: [
{
name: 'assm',
cwd: '/root/msgapp',
script: 'npm',
args: 'start',
interpreter: 'none',
env: {
NODE_ENV: 'production',
},
autorestart: true,
watch: false,
},
],
};

3
middleware.ts Normal file
View File

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

View File

@@ -5,7 +5,7 @@
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"start": "next start --port 3100",
"lint": "biome check",
"format": "biome format --write",
"migrate": "prisma migrate dev",

View File

@@ -0,0 +1,43 @@
/*
Warnings:
- You are about to drop the column `requiresApproval` on the `boards` table. All the data in the column will be lost.
- You are about to drop the column `type` on the `boards` table. All the data in the column will be lost.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_boards" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'active',
"allowAnonymousPost" BOOLEAN NOT NULL DEFAULT false,
"allowSecretComment" BOOLEAN NOT NULL DEFAULT false,
"isAdultOnly" BOOLEAN NOT NULL DEFAULT false,
"requiredTags" JSONB,
"requiredFields" JSONB,
"readLevel" TEXT NOT NULL DEFAULT 'public',
"writeLevel" TEXT NOT NULL DEFAULT 'member',
"categoryId" TEXT,
"mainPageViewTypeId" TEXT,
"listViewTypeId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "boards_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "board_categories" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "boards_mainPageViewTypeId_fkey" FOREIGN KEY ("mainPageViewTypeId") REFERENCES "board_view_types" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "boards_listViewTypeId_fkey" FOREIGN KEY ("listViewTypeId") REFERENCES "board_view_types" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_boards" ("allowAnonymousPost", "allowSecretComment", "categoryId", "createdAt", "description", "id", "isAdultOnly", "listViewTypeId", "mainPageViewTypeId", "name", "readLevel", "requiredFields", "requiredTags", "slug", "sortOrder", "status", "updatedAt", "writeLevel") SELECT "allowAnonymousPost", "allowSecretComment", "categoryId", "createdAt", "description", "id", "isAdultOnly", "listViewTypeId", "mainPageViewTypeId", "name", "readLevel", "requiredFields", "requiredTags", "slug", "sortOrder", "status", "updatedAt", "writeLevel" FROM "boards";
DROP TABLE "boards";
ALTER TABLE "new_boards" RENAME TO "boards";
CREATE UNIQUE INDEX "boards_slug_key" ON "boards"("slug");
CREATE INDEX "boards_status_sortOrder_idx" ON "boards"("status", "sortOrder");
CREATE INDEX "boards_categoryId_idx" ON "boards"("categoryId");
CREATE INDEX "boards_mainPageViewTypeId_idx" ON "boards"("mainPageViewTypeId");
CREATE INDEX "boards_listViewTypeId_idx" ON "boards"("listViewTypeId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,26 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_comments" (
"id" TEXT NOT NULL PRIMARY KEY,
"postId" TEXT NOT NULL,
"parentId" TEXT,
"depth" INTEGER NOT NULL DEFAULT 0,
"authorId" TEXT,
"content" TEXT NOT NULL,
"isAnonymous" BOOLEAN NOT NULL DEFAULT false,
"isSecret" BOOLEAN NOT NULL DEFAULT false,
"secretPasswordHash" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "comments_postId_fkey" FOREIGN KEY ("postId") REFERENCES "posts" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "comments_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "comments" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "comments_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users" ("userId") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_comments" ("authorId", "content", "createdAt", "id", "isAnonymous", "isSecret", "postId", "secretPasswordHash", "updatedAt") SELECT "authorId", "content", "createdAt", "id", "isAnonymous", "isSecret", "postId", "secretPasswordHash", "updatedAt" FROM "comments";
DROP TABLE "comments";
ALTER TABLE "new_comments" RENAME TO "comments";
CREATE INDEX "comments_postId_createdAt_idx" ON "comments"("postId", "createdAt");
CREATE INDEX "comments_parentId_idx" ON "comments"("parentId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

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")
@@ -324,6 +326,8 @@ model UserRole {
model Comment {
id String @id @default(cuid())
postId String
parentId String? // 부모 댓글 ID (null이면 최상위 댓글)
depth Int @default(0) // 댓글 깊이 (0=최상위, 1=1단계 대댓글, 2=2단계 대댓글)
authorId String?
content String
isAnonymous Boolean @default(false)
@@ -333,10 +337,13 @@ model Comment {
updatedAt DateTime @updatedAt
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
replies Comment[] @relation("CommentReplies")
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
reports Report[]
@@index([postId, createdAt])
@@index([parentId])
@@map("comments")
}
@@ -432,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())
@@ -501,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())
@@ -669,6 +705,7 @@ model Partner {
id String @id @default(cuid())
name String @unique
category String
categoryId String?
latitude Float
longitude Float
address String?
@@ -677,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())
@@ -724,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?
@@ -744,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

@@ -1,7 +1,12 @@
const { PrismaClient } = require("@prisma/client");
const { createHash } = require("crypto");
const prisma = new PrismaClient();
function hashPassword(plain) {
return createHash("sha256").update(plain, "utf8").digest("hex");
}
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
@@ -32,6 +37,75 @@ function generateNickname(i) {
return `user${String(i + 1).padStart(3, "0")}${suffix}`;
}
// 랜덤 제목/문장/이미지 도우미
const TITLE_FRAGMENTS = [
// 아주 짧은 키워드
"공지", "업뎃", "버그", "요청", "후기", "정보", "TIP", "사진", "잡담", "나눔",
"질문", "헬프", "리뷰", "이슈", "주의", "긴급", "정리", "모음", "요약", "스샷",
// 짧은 구문
"오늘의 이슈", "핫 토픽", "소소한 일상", "정보 공유", "꿀팁 모음",
"개발 노트", "버그 리포트", "아이디어 제안", "함께 보아요",
];
const SENTENCES = [
"안녕하세요, 간단히 공유 드립니다.",
"도움이 되셨다면 댓글로 알려주세요.",
"의견이나 질문은 언제든 환영입니다.",
"테스트로 작성된 시드 데이터입니다.",
"참고용 스크린샷을 함께 첨부합니다.",
"관련 경험 있으시면 팁 부탁드려요.",
"문서화가 필요해 간단히 정리했습니다.",
"링크와 자료를 함께 첨부합니다.",
"개선 제안은 자유롭게 남겨주세요.",
"읽어주셔서 감사합니다.",
];
const TITLE_SUBS = [
"지금", "방금", "오늘", "금일", "v2", "2025", "베타", "테스트",
"임시", "간단히", "빠르게", "짧게", "새로", "업데이트", "정리", "공유",
];
const TITLE_EMOJIS = ["🔥", "📌", "✅", "❗", "💡", "🆕", "🔧", "📝", "📷"];
function clampTitle(s, max = 60) {
return s.length <= max ? s : s.slice(0, max).trim();
}
function pick(arr) { return arr[randomInt(0, arr.length - 1)]; }
function coin(p = 0.5) { return Math.random() < p; }
function randomTitle(boardName, index) {
// 다양한 템플릿으로 제목 생성 (짧은 것도, 긴 것도 포함)
const a = pick(TITLE_FRAGMENTS);
const b = pick(TITLE_FRAGMENTS);
const sub = pick(TITLE_SUBS);
const emoji = pick(TITLE_EMOJIS);
const num = (index % 99) + 1;
const templates = [
() => `${a}`,
() => `${a} ${emoji}`,
() => `${a} #${num}`,
() => `${a} ${sub}`,
() => `${a} · ${b}`,
() => `[${a}] ${b}`,
() => `${a}: ${b}`,
() => `${a} ${b} ${emoji}`,
// 가끔만 보드명 포함
() => `${boardName} ${a}`,
() => `${boardName} ${a} · ${b}`,
];
// 짧은 제목 확률을 높이기 위해 템플릿 선택 가중치 없이 랜덤
const title = pick(templates)();
return clampTitle(title, 60);
}
function randomSentence() {
return SENTENCES[randomInt(0, SENTENCES.length - 1)];
}
function randomImageUrl(seedKey, w = 800, h = 450) {
// 외부 랜덤 이미지. 네트워크가 제한되면 /sample.jpg로 대체 가능
const seed = encodeURIComponent(String(seedKey));
return `https://picsum.photos/seed/${seed}/${w}/${h}`;
}
async function createRandomUsers(count = 100) {
const roleUser = await prisma.role.findUnique({ where: { name: "user" } });
// 사용되지 않은 전화번호를 찾는 보조 함수
@@ -45,27 +119,48 @@ 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("12341234"),
agreementTermsAt: new Date(),
authLevel: "USER",
isAdultVerified: Math.random() < 0.6,
lastLoginAt: Math.random() < 0.8 ? new Date() : null,
},
});
} else {
// 기존 사용자도 패스워드를 1234로 업데이트
await prisma.user.update({
where: { userId: user.userId },
data: { passwordHash: hashPassword("12341234") },
});
}
if (roleUser && user) {
await prisma.userRole.upsert({
@@ -74,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() {
@@ -155,14 +252,24 @@ async function upsertRoles() {
async function upsertAdmin() {
const admin = await prisma.user.upsert({
where: { nickname: "admin" },
update: {},
update: {
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("12341234"),
agreementTermsAt: new Date(),
authLevel: "ADMIN",
grade: 7,
points: 1650000,
level: 200,
},
});
@@ -179,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 = [
// 일반
@@ -188,7 +344,6 @@ async function upsertBoards(admin, categoryMap) {
{ name: "이벤트", slug: "event", description: "이벤트", sortOrder: 4, requiredTags: { required: ["이벤트"] } },
{ name: "자유게시판", slug: "free", description: "자유", sortOrder: 5 },
{ name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", sortOrder: 6 },
{ name: "마사지꿀팁", slug: "tips", description: "팁", sortOrder: 7 },
{ name: "익명게시판", slug: "anonymous", description: "익명", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true },
{ name: "청와대", slug: "blue-house", description: "레벨 제한", sortOrder: 10, readLevel: "member" },
// 특수
@@ -200,9 +355,12 @@ async function upsertBoards(admin, categoryMap) {
];
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 listSpecialAttendance = await prisma.boardViewType.findUnique({ where: { key: "list_special_attendance" } });
for (const b of boards) {
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
@@ -234,8 +392,12 @@ async function upsertBoards(admin, categoryMap) {
allowAnonymousPost: !!b.allowAnonymousPost,
readLevel: b.readLevel || undefined,
categoryId: category ? category.id : undefined,
// 기본은 텍스트, '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,
@@ -245,8 +407,12 @@ async function upsertBoards(admin, categoryMap) {
allowAnonymousPost: !!b.allowAnonymousPost,
readLevel: b.readLevel || undefined,
categoryId: category ? category.id : undefined,
// 기본은 텍스트, '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);
@@ -267,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({
@@ -298,16 +463,23 @@ 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)
? admin.userId
: userIds[randomInt(0, userIds.length - 1)];
const title = randomTitle(board.name, i);
const img = randomImageUrl(`${board.slug}-${i}`);
const p1 = randomSentence();
const p2 = randomSentence();
const p3 = randomSentence();
data.push({
boardId: board.id,
authorId,
title: `${board.name} 샘플 글 ${i + 1}`,
content: `이 게시판(${board.slug})의 자동 시드 게시물 #${i + 1} 입니다.\n\n테스트용 내용입니다.`,
title,
content: `<p>${p1}</p>\n<figure><img src="${img}" alt="seed image" /></figure>\n<p>${p2}</p>\n<p>${p3}</p>`,
status: "published",
isAnonymous: board.allowAnonymousPost ? Math.random() < 0.3 : false,
});
@@ -352,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 },
});
}
// 표시 토글 기본값 보장
@@ -370,16 +542,112 @@ async function seedPartnerShops() {
});
}
async function seedBanners() {
// 기존 배너 정리 후 5개만 채움
await prisma.banner.deleteMany({});
const items = Array.from({ length: 5 }).map((_, i) => ({
title: `메인 배너 ${i + 1}`,
imageUrl: "/sample.jpg",
linkUrl: "/",
sortOrder: i + 1,
active: true,
}));
await prisma.banner.createMany({ data: items });
}
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"]);
const visibleBoardIds = boards.filter((b) => wantSlugs.has(b.slug)).map((b) => b.id);
const next = { ...current, visibleBoardIds };
await prisma.setting.upsert({
where: { key: SETTINGS_KEY },
update: { value: JSON.stringify(next) },
create: { key: SETTINGS_KEY, value: JSON.stringify(next) },
});
}
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();
await seedBanners();
await seedPolicies();
@@ -388,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 });
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 99 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 69 KiB

9
public/svgs/03_gold.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 70 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 92 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 95 KiB

9
public/svgs/08_god.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

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

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import type { Prisma } from "@prisma/client";
import { z } from "zod";
export async function GET() {
@@ -24,7 +25,7 @@ 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 banner = await prisma.banner.create({ data: parsed.data });
const banner = await prisma.banner.create({ data: parsed.data as Prisma.BannerCreateInput });
return NextResponse.json({ banner }, { status: 201 });
}

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import type { Prisma } from "@prisma/client";
import { z } from "zod";
export async function GET() {
@@ -54,7 +55,13 @@ export async function POST(req: Request) {
});
sortOrder = (max._max.sortOrder ?? 0) + 1;
}
const created = await prisma.board.create({ data: { ...data, sortOrder } });
const { categoryId, sortOrder: _ignored, ...rest } = data;
const createData: Prisma.BoardCreateInput = {
...(rest as any),
sortOrder,
...(categoryId ? { category: { connect: { id: categoryId } } } : {}),
};
const created = await prisma.board.create({ data: createData });
return NextResponse.json({ board: created }, { status: 201 });
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
import type { Prisma } from "@prisma/client";
export async function GET() {
const categories = await prisma.boardCategory.findMany({
@@ -20,7 +21,7 @@ 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 category = await prisma.boardCategory.create({ data: parsed.data });
const category = await prisma.boardCategory.create({ data: parsed.data as Prisma.BoardCategoryCreateInput });
return NextResponse.json({ category }, { status: 201 });
}

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

@@ -1,10 +1,15 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
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({
@@ -20,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 });
// 통합: 승인된 레코드로 등록
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 });
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

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
import type { Prisma } from "@prisma/client";
export async function GET() {
const items = await prisma.boardViewType.findMany({ orderBy: [{ scope: 'asc' }, { name: 'asc' }] });
@@ -19,7 +20,7 @@ export async function POST(req: Request) {
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const exists = await prisma.boardViewType.findFirst({ where: { key: parsed.data.key } });
if (exists) return NextResponse.json({ error: 'duplicate_key' }, { status: 409 });
const created = await prisma.boardViewType.create({ data: parsed.data });
const created = await prisma.boardViewType.create({ data: parsed.data as Prisma.BoardViewTypeCreateInput });
return NextResponse.json({ item: created }, { status: 201 });
}

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({
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

@@ -2,9 +2,11 @@ import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
import { hashPassword } from "@/lib/password";
import { getUserIdOrAdmin } from "@/lib/auth";
const createCommentSchema = z.object({
postId: z.string().min(1),
parentId: z.string().nullable().optional(), // 부모 댓글 ID (대댓글). 최상위 댓글은 null 허용
authorId: z.string().optional(),
content: z.string().min(1),
isAnonymous: z.boolean().optional(),
@@ -18,17 +20,51 @@ export async function POST(req: Request) {
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { postId, authorId, content, isAnonymous, isSecret, secretPassword } = parsed.data;
const { postId, parentId, authorId, content, isAnonymous, isSecret, secretPassword } = parsed.data;
// 개발 편의: 인증 미설정 시 admin 계정으로 댓글 작성 처리
const effectiveAuthorId = authorId ?? (await getUserIdOrAdmin(req));
// 대댓글인 경우 부모 댓글 확인 및 depth 계산
let depth = 0;
if (parentId) {
const parent = await prisma.comment.findUnique({
where: { id: parentId },
select: { depth: true, postId: true },
});
if (!parent) {
return NextResponse.json({ error: "부모 댓글을 찾을 수 없습니다." }, { status: 404 });
}
if (parent.postId !== postId) {
return NextResponse.json({ error: "게시글이 일치하지 않습니다." }, { status: 400 });
}
depth = parent.depth + 1;
if (depth > 2) {
return NextResponse.json({ error: "최대 3단계까지 대댓글을 작성할 수 있습니다." }, { status: 400 });
}
}
const secretPasswordHash = secretPassword ? hashPassword(secretPassword) : null;
const comment = await prisma.comment.create({
data: {
postId,
authorId: authorId ?? null,
parentId: parentId ?? null,
depth,
authorId: effectiveAuthorId ?? null,
content,
isAnonymous: !!isAnonymous,
isSecret: !!isSecret,
secretPasswordHash,
},
include: {
author: { select: { userId: true, nickname: true, profileImage: true } },
},
});
// 통계 업데이트: 댓글 수 증가
await prisma.postStat.upsert({
where: { postId },
update: { commentsCount: { increment: 1 } },
create: { postId, commentsCount: 1 },
});
return NextResponse.json({ comment }, { status: 201 });
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
import type { Prisma } from "@prisma/client";
export async function GET() {
const now = new Date();
@@ -30,7 +31,7 @@ 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 data = await prisma.coupon.create({ data: parsed.data });
const data = await prisma.coupon.create({ data: parsed.data as Prisma.CouponCreateInput });
return NextResponse.json({ coupon: data }, { status: 201 });
}

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

22
src/app/api/me/route.ts Normal file
View File

@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { getUserIdOrAdmin } from "@/lib/auth";
export async function GET(req: Request) {
const userId = await getUserIdOrAdmin(req);
if (!userId) return NextResponse.json({ user: null });
const user = await prisma.user.findUnique({
where: { userId },
select: {
userId: true,
nickname: true,
profileImage: true,
points: true,
level: true,
grade: true,
},
});
return NextResponse.json({ user });
}

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

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
import type { Prisma } from "@prisma/client";
const schema = z.object({ name: z.string().min(1), contact: z.string().min(1), category: z.string().optional(), message: z.string().min(1) });
@@ -8,7 +9,7 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const parsed = schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const created = await prisma.partnerInquiry.create({ data: parsed.data });
const created = await prisma.partnerInquiry.create({ data: parsed.data as Prisma.PartnerInquiryCreateInput });
return NextResponse.json({ inquiry: created }, { status: 201 });
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
import type { Prisma } from "@prisma/client";
const schema = z.object({ name: z.string().min(1), category: z.string().min(1), latitude: z.coerce.number(), longitude: z.coerce.number(), address: z.string().optional(), contact: z.string().optional() });
@@ -8,7 +9,7 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const parsed = schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const created = await prisma.partnerRequest.create({ data: parsed.data });
const created = await prisma.partnerRequest.create({ data: parsed.data as Prisma.PartnerRequestCreateInput });
return NextResponse.json({ request: created }, { status: 201 });
}

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,28 +1,70 @@
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 comments = await prisma.comment.findMany({
where: { postId: id },
const requesterId = getUserIdFromRequest(req);
const post = await prisma.post.findUnique({
where: { id },
select: { authorId: true },
});
const postAuthorId = post?.authorId ?? null;
// 최상위 댓글만 가져오기
const topComments = await prisma.comment.findMany({
where: {
postId: id,
parentId: null, // 최상위 댓글만
},
orderBy: { createdAt: "asc" },
select: {
id: true,
content: true,
isAnonymous: true,
isSecret: true,
secretPasswordHash: true,
createdAt: true,
include: {
author: { select: { userId: true, nickname: true, profileImage: true } },
replies: {
include: {
author: { select: { userId: true, nickname: true, profileImage: true } },
replies: {
include: {
author: { select: { userId: true, nickname: true, profileImage: true } },
},
orderBy: { createdAt: "asc" },
},
},
orderBy: { createdAt: "asc" },
},
},
});
const presented = comments.map((c) => ({
id: c.id,
content: c.isSecret ? "비밀댓글입니다." : c.content,
isAnonymous: c.isAnonymous,
isSecret: c.isSecret,
anonId: c.isAnonymous ? c.id.slice(-6) : undefined,
createdAt: c.createdAt,
}));
// 재귀적으로 댓글 구조 변환
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

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
import { getUserIdOrAdmin } from "@/lib/auth";
const createPostSchema = z.object({
boardId: z.string().min(1),
@@ -17,6 +18,8 @@ export async function POST(req: Request) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { boardId, authorId, title, content, isAnonymous } = parsed.data;
// 개발 편의를 위해, 인증 정보가 없으면 admin 사용자로 대체
const effectiveAuthorId = authorId ?? (await getUserIdOrAdmin(req));
const board = await prisma.board.findUnique({ where: { id: boardId } });
// 사진형 보드 필수 이미지 검증: content 내 이미지 링크 최소 1개
const isImageOnly = (board?.requiredFields as any)?.imageOnly;
@@ -30,7 +33,7 @@ export async function POST(req: Request) {
const post = await prisma.post.create({
data: {
boardId,
authorId: authorId ?? null,
authorId: effectiveAuthorId ?? null,
title,
content,
isAnonymous: !!isAnonymous,
@@ -45,9 +48,10 @@ 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
start: z.coerce.date().optional(), // createdAt >= start
end: z.coerce.date().optional(), // createdAt <= end
});
@@ -58,7 +62,7 @@ export async function GET(req: Request) {
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { page, pageSize, boardId, q, sort = "recent", tag, author, start, end } = parsed.data;
const { page, pageSize, boardId, q, sort = "recent", tag, author, authorId, start, end } = parsed.data;
const where = {
NOT: { status: "deleted" as const },
...(boardId ? { boardId } : {}),
@@ -75,7 +79,11 @@ export async function GET(req: Request) {
postTags: { some: { tag: { slug: tag } } },
}
: {}),
...(author
...(authorId
? {
authorId,
}
: author
? {
author: { nickname: { contains: author } },
}
@@ -95,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,
@@ -105,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

@@ -1,87 +1,153 @@
import { PostList } from "@/app/components/PostList";
import { HeroBanner } from "@/app/components/HeroBanner";
import Link from "next/link";
import { BoardToolbar } from "@/app/components/BoardToolbar";
import { headers } from "next/headers";
import prisma from "@/lib/prisma";
import { UserAvatar } from "@/app/components/UserAvatar";
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 period = (sp?.period as string | undefined) ?? "monthly";
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);
const id = board?.id as string;
const siblingBoards = (boards || []).filter((b: any) => b.category?.id && b.category.id === board?.category?.id);
const categoryName = board?.category?.name ?? "";
// 메인배너 표시 설정
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;
// 리스트 뷰 타입 확인 (특수랭킹일 경우 게시글 대신 랭킹 노출)
// 리스트 뷰 타입 확인 (특수랭킹/출석부 등)
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 }[] = [];
let rankingItems: { userId: string; nickname: string; points: number; profileImage: string | null; grade: number }[] = [];
if (isSpecialRanking) {
const rankingUrl = new URL(`/api/ranking?period=${encodeURIComponent(period)}`, base).toString();
const rankingRes = await fetch(rankingUrl, { cache: "no-store" });
const rankingData = await rankingRes.json().catch(() => ({ items: [] }));
rankingItems = rankingData?.items ?? [];
const topUsers = await prisma.user.findMany({
select: { userId: true, nickname: true, points: true, profileImage: true, grade: true },
where: { status: "active" },
orderBy: { points: "desc" },
take: 100,
});
rankingItems = topUsers.map((u) => ({ userId: u.userId, nickname: u.nickname, points: u.points, profileImage: u.profileImage, grade: u.grade }));
}
return (
<div className="space-y-6">
{/* 상단 배너 (서브카테고리 표시) */}
{showBanner ? (
<section>
<HeroBanner
subItems={siblingBoards.map((b: any) => ({ id: b.id, name: b.name, href: `/boards/${b.slug}` }))}
activeSubId={id}
/>
</section>
) : (
<section>
<div className="h-[58px] bg-black rounded-xl flex items-center justify-center px-2">
<div className="flex flex-wrap items-center gap-[8px]">
{siblingBoards.map((b: any) => (
<Link
key={b.id}
href={`/boards/${b.slug}`}
className={
b.id === id
? "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}
</Link>
))}
</div>
</div>
</section>
)}
{/* 검색/필터 툴바 + 리스트 */}
<section>
<BoardToolbar boardId={board?.slug} />
<section className="px-[0px] md:px-[30px] ">
{!isSpecialRanking && !isAttendance && <BoardToolbar boardId={board?.slug} />}
<div className="p-0">
{isSpecialRanking ? (
<div className="rounded-xl border border-neutral-200 overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 flex items-center justify-between bg-[#f6f4f4]">
<h2 className="text-sm text-neutral-700"> ({period})</h2>
<div className="text-xs text-neutral-500 flex gap-2">
<a href={`?period=daily`} className={`px-2 py-0.5 rounded ${period === "daily" ? "bg-neutral-900 text-white" : "bg-white border border-neutral-300"}`}></a>
<a href={`?period=weekly`} className={`px-2 py-0.5 rounded ${period === "weekly" ? "bg-neutral-900 text-white" : "bg-white border border-neutral-300"}`}></a>
<a href={`?period=monthly`} className={`px-2 py-0.5 rounded ${period === "monthly" ? "bg-neutral-900 text-white" : "bg-white border border-neutral-300"}`}></a>
<a href={`?period=all`} className={`px-2 py-0.5 rounded ${period === "all" ? "bg-neutral-900 text-white" : "bg-white border border-neutral-300"}`}></a>
<div className="w-full">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[32px]">
{rankingItems.map((i, idx) => {
const rank = idx + 1;
return (
<div key={i.userId} className="border-t border-[#d5d5d5]">
<div className="flex gap-[16px] items-center p-[16px]">
<div className="flex items-center gap-[8px] shrink-0">
{(rank === 1 || rank === 2 || rank === 3) && (
<div className="relative w-[20px] h-[20px] shrink-0">
{rank === 1 && <RankIcon1st />}
{rank === 2 && <RankIcon2nd />}
{rank === 3 && <RankIcon3rd />}
</div>
</div>
<ol className="divide-y divide-neutral-200">
{rankingItems.map((i, idx) => (
<li key={i.userId} className="px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0">
<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 text-neutral-900 font-medium">{i.nickname || "회원"}</span>
</div>
<div className="shrink-0 text-sm text-neutral-700">{i.points}</div>
</li>
))}
{rankingItems.length === 0 && (
<li className="px-4 py-10 text-center text-neutral-500"> .</li>
)}
</ol>
<div className="bg-white border border-[#d5d5d5] px-[16px] py-[5px] rounded-[12px] text-[14px] text-[#5c5c5c] shrink-0">
{rank}
</div>
<div className="flex items-center gap-[10px] shrink-0 pl-0 pr-[15px] py-0">
<UserAvatar src={i.profileImage} alt={i.nickname || "프로필"} width={36} height={36} className="rounded-full" />
<span className="text-[16px] text-[#5c5c5c] leading-[16px] tracking-[-0.28px] whitespace-nowrap">
{i.nickname || "회원"}
</span>
<GradeIcon grade={i.grade} width={20} height={20} />
</div>
</div>
<div className="flex items-center gap-[13px] shrink-0 ml-auto">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0">
<path d="M8.17377 0.0481988C8.1834 0.0326271 8.19596 0.0201252 8.21037 0.0117499C8.22478 0.00337451 8.24062 -0.000628204 8.25656 7.99922e-05C8.2725 0.000788189 8.28806 0.0061865 8.30194 0.0158187C8.31582 0.0254509 8.3276 0.0390338 8.33629 0.0554196L11.3323 5.55973C11.3575 5.60512 11.4038 5.62472 11.4468 5.60718L15.8683 3.80198C15.9441 3.77104 16.0174 3.85666 15.9963 3.95053L14.2584 16H1.74226L0.00344328 3.95156C-0.00125237 3.93019 -0.0011435 3.90765 0.00375838 3.88635C0.00866025 3.86505 0.0181727 3.84576 0.0312889 3.83054C0.0444051 3.81532 0.0606369 3.80472 0.0782661 3.79988C0.0958953 3.79503 0.114266 3.79612 0.131434 3.80302L4.55889 5.61131C4.59846 5.62678 4.64309 5.61131 4.66835 5.57005L8.17461 0.0481988H8.17377Z" fill="#FFB625"/>
</svg>
<span className="text-[16px] font-semibold text-[#5c5c5c] leading-[22px]">{i.points.toLocaleString()}</span>
</div>
</div>
</div>
);
})}
</div>
{rankingItems.length === 0 && (
<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

@@ -1,13 +1,15 @@
import Link from "next/link";
export function AppFooter() {
return (
<footer className="py-[72px]">
<div className="text-[#626262] text-[16px] leading-[14px] flex flex-row mb-[30px]">
<div className="flex-1"></div>
<div className="border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"></div>
<div className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"> </div>
<div className=" border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"> </div>
<div className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"></div>
<div className="px-[8px] cursor-pointer hover:text-[#2BAF7E]"></div>
<Link href="/privacy" className="border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"></Link>
<Link href="/email-deny" className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"> </Link>
<Link href="/legal" className=" border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"> </Link>
<Link href="/guide" className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]"></Link>
<Link href="/contact" className="px-[8px] cursor-pointer hover:text-[#2BAF7E]"></Link>
<div className="flex-1"></div>
</div>
<div className="text-[#888] text-center font-[Pretendard] text-[16px] font-normal leading-[100%] mt-[24px]">

View File

@@ -2,7 +2,10 @@
// 클라이언트 훅(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";
import React from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { SinglePageLogo } from "@/app/components/SinglePageLogo";
@@ -18,11 +21,26 @@ export function AppHeader() {
const panelRef = React.useRef<HTMLDivElement | null>(null);
const blockRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
const navItemRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
const navRowRef = React.useRef<HTMLDivElement | null>(null);
const navTextRefs = React.useRef<Record<string, HTMLSpanElement | null>>({});
const [leftPositions, setLeftPositions] = React.useState<Record<string, number>>({});
const [panelHeight, setPanelHeight] = React.useState<number>(0);
const [blockWidths, setBlockWidths] = React.useState<Record<string, number>>({});
const closeTimer = React.useRef<number | null>(null);
const [navMinWidths, setNavMinWidths] = React.useState<Record<string, number>>({});
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,
(u: string) => fetch(u).then((r) => r.json())
);
// 현재 경로 기반 활성 보드/카테고리 계산
const pathname = usePathname();
@@ -72,6 +90,40 @@ export function AppHeader() {
return () => window.removeEventListener("categories:reload", onRefresh);
}, [reloadCategories]);
// 상단 네비게이션 선택/호버 인디케이터 업데이트
const updateIndicator = React.useCallback(() => {
const container = navRowRef.current;
if (!container) return;
const targetSlug = (megaOpen && openSlug) ? openSlug : activeCategorySlug;
if (!targetSlug) {
setIndicatorVisible(false);
return;
}
const itemEl = navItemRefs.current[targetSlug];
if (!itemEl) {
setIndicatorVisible(false);
return;
}
const cr = container.getBoundingClientRect();
const ir = itemEl.getBoundingClientRect();
const inset = 0; // 컨테이너(div) 너비 기준
const nextLeft = Math.max(0, ir.left - cr.left + inset);
const nextWidth = Math.max(0, ir.width - inset * 2);
setIndicatorLeft(nextLeft);
setIndicatorWidth(nextWidth);
setIndicatorVisible(true);
}, [megaOpen, openSlug, activeCategorySlug]);
React.useEffect(() => {
updateIndicator();
}, [updateIndicator, categories]);
React.useEffect(() => {
const onResize = () => updateIndicator();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [updateIndicator]);
// ESC로 메가메뉴 닫기
React.useEffect(() => {
if (!megaOpen) return;
@@ -294,22 +346,27 @@ 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">
<header
ref={headerRef}
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)]" : ""
}`}
>
<div className="flex items-center gap-3 z-[100]">
<button
aria-label="메뉴 열기"
aria-expanded={mobileOpen}
aria-controls="mobile-menu"
onClick={() => setMobileOpen((v) => !v)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-neutral-200 xl:hidden"
className="group inline-flex h-8 w-8 items-center justify-center rounded-md border border-neutral-200 hover:border-neutral-300 hover:bg-neutral-100 transition-colors cursor-pointer xl:hidden"
>
<span className="flex flex-col items-center justify-center gap-1">
<span className="block h-0.5 w-4 bg-neutral-800" />
<span className="block h-0.5 w-4 bg-neutral-800" />
<span className="block h-0.5 w-4 bg-neutral-800" />
<span className="block h-0.5 w-4 bg-neutral-800 group-hover:bg-[var(--red-50,#F94B37)] transition-colors" />
<span className="block h-0.5 w-4 bg-neutral-800 group-hover:bg-[var(--red-50,#F94B37)] transition-colors" />
<span className="block h-0.5 w-4 bg-neutral-800 group-hover:bg-[var(--red-50,#F94B37)] transition-colors" />
</span>
</button>
<Link href="/" aria-label="홈" className="shrink-0">
<Link href="/" aria-label="홈" className="shrink-0 flex items-center">
<SinglePageLogo width={120} height={28} className="w-20 xl:w-[120px] h-auto" />
</Link>
</div>
@@ -325,7 +382,7 @@ export function AppHeader() {
if (!e.currentTarget.contains(next)) setMegaOpen(false);
}}
>
<div className="flex items-center gap-8">
<div className="relative flex items-center gap-0" ref={navRowRef}>
{categories.map((cat, idx) => (
<div
key={cat.id}
@@ -334,31 +391,44 @@ export function AppHeader() {
ref={(el) => {
navItemRefs.current[cat.slug] = el;
}}
style={idx === categories.length - 1 ? { minWidth: 120 } : undefined}
>
<Link
href={cat.boards?.[0]?.slug ? `/boards/${cat.boards[0].slug}` : `/boards?category=${cat.slug}`}
className={`block w-full px-2 py-2 text-sm font-medium transition-colors duration-200 hover:text-neutral-900 whitespace-nowrap ${
activeCategorySlug === cat.slug ? "text-neutral-900" : "text-neutral-700"
className={`block w-full px-6 py-2 text-sm font-medium transition-colors duration-200 whitespace-nowrap ${
(megaOpen && openSlug === cat.slug) || activeCategorySlug === cat.slug ? "" : "text-neutral-700"
}`}
style={(megaOpen && openSlug === cat.slug) || activeCategorySlug === cat.slug ? { color: "var(--red-50, #F94B37)" } : undefined}
ref={(el) => {
navRefs.current[cat.slug] = el;
}}
>
{cat.name}
</Link>
<span
className={`pointer-events-none absolute left-1 right-1 -bottom-0.5 h-0.5 origin-left rounded bg-neutral-900 transition-all duration-200 ${
(megaOpen && openSlug === cat.slug) || activeCategorySlug === cat.slug
? "scale-x-100 opacity-100"
: "scale-x-0 opacity-0 group-hover:opacity-100 group-hover:scale-x-100"
}`}
/>
ref={(el) => {
navTextRefs.current[cat.slug] = el;
}}
className="inline-block"
>
{cat.name}
</span>
</Link>
</div>
))}
{/* 공용 선택 인디케이터 바 (Figma 스타일) */}
<span
aria-hidden
className="pointer-events-none absolute bottom-0 left-0 transition-all duration-300 ease-out"
style={{
transform: `translateX(${indicatorLeft}px)`,
width: indicatorWidth,
height: 4,
opacity: indicatorVisible ? 1 : 0,
background: "var(--red-50, #F94B37)",
borderRadius: "var(--radius-24, 24px) var(--radius-24, 24px) 0 0",
}}
/>
</div>
<div
className={`fixed left-0 right-0 z-50 border-t bg-white shadow-[0_8px_24px_rgba(0,0,0,0.08)] 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 }}
@@ -377,13 +447,15 @@ export function AppHeader() {
style={{ left: (leftPositions[cat.slug] ?? 0), width: blockWidths[cat.slug] ?? undefined }}
>
{/* <div className="mb-2 font-semibold text-neutral-800">{cat.name}</div> */}
<div className="mx-auto flex flex-col items-center gap-3 w-full">
<div className="mx-auto flex flex-col items-center gap-0 w-full">
{cat.boards.map((b) => (
<Link
key={b.id}
href={`/boards/${b.slug}`}
className={`rounded px-2 py-1 text-sm transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-900 text-center whitespace-nowrap ${
activeBoardId === b.slug ? "bg-neutral-100 text-neutral-900 font-medium" : "text-neutral-700"
className={`rounded px-2 pb-4 text-sm transition-colors duration-150 text-center whitespace-nowrap ${
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}
>
@@ -399,24 +471,130 @@ export function AppHeader() {
</div>
</div>
<div id="dummy" className="block"></div>
<div className="hidden xl:flex xl:flex-1 justify-end">
<div className="hidden xl:flex xl:flex-1 justify-end items-center gap-3">
<SearchBar/>
{authData?.user ? (
<>
{/* 인사 + 로그아웃을 하나의 배지로 묶어 자연스럽게 표시 */}
<div className="inline-flex items-center h-10 rounded-md border border-neutral-300 bg-white overflow-hidden">
<Link
href="/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>
{mobileOpen && (
<div className="fixed inset-0 h-[100vh] z-40 bg-black/60 backdrop-blur-sm" onClick={() => setMobileOpen(false)}>
<div className="absolute left-0 xl:top-0 h-[100vh] w-11/12 max-w-md bg-white p-4 shadow-xl overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div
className={`fixed inset-0 h-[100vh] z-40 bg-black/60 backdrop-blur-sm transition-opacity duration-300 ${mobileOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
onClick={() => setMobileOpen(false)}
aria-hidden={!mobileOpen}
>
<div
className={`absolute left-0 xl:top-0 h-[100vh] w-11/12 max-w-md bg-white p-4 shadow-xl overflow-y-auto transition-transform duration-300 ${mobileOpen ? "translate-x-0" : "-translate-x-full"}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<div className="mb-3 h-10 flex items-center justify-between">
</div>
<div className="flex flex-col gap-4">
<SearchBar />
{/* 미니 프로필 패널 */}
<div className="rounded-xl border border-neutral-200 p-3">
{meData?.user ? (
<div className="flex items-center gap-3">
<UserAvatar src={meData.user.profileImage} alt={meData.user.nickname || "프로필"} width={48} height={48} className="rounded-full" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-neutral-900 truncate">{meData.user.nickname}</span>
<GradeIcon grade={meData.user.grade} width={20} height={20} />
</div>
<div className="text-xs text-neutral-600">Lv.{meData.user.level} · {meData.user.points.toLocaleString()}</div>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<div className="text-sm text-neutral-700"> </div>
<Link
href={`/login?next=${encodeURIComponent((pathname || "/") + (searchParams?.toString() ? `?${searchParams.toString()}` : ""))}`}
onClick={() => setMobileOpen(false)}
className="h-9 px-3 inline-flex items-center rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
aria-label="로그인"
>
</Link>
</div>
)}
{meData?.user && (
<div className="mt-3 flex justify-end">
<button
onClick={async () => {
try {
await fetch("/api/auth/session", { method: "DELETE" });
} finally {
setMobileOpen(false);
window.location.reload();
}
}}
className="h-9 px-3 inline-flex items-center rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
aria-label="로그아웃"
>
</button>
</div>
)}
{meData?.user && (
<div className="grid grid-cols-3 gap-2 mt-3">
<Link href="/my-page?tab=points" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100"> </Link>
<Link href="/my-page?tab=posts" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100"> </Link>
<Link href="/my-page?tab=comments" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100"> </Link>
</div>
)}
</div>
<SearchBar 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>
))}
@@ -427,7 +605,6 @@ export function AppHeader() {
</div>
</div>
</div>
)}
</header>
);
}

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

@@ -0,0 +1,47 @@
"use client";
import { useEffect } from "react";
export function AutoLoginAdmin() {
useEffect(() => {
// 쿠키에 uid가 없으면 어드민으로 자동 로그인
const checkCookie = () => {
const cookies = document.cookie.split(";");
const uidCookie = cookies.find((cookie) => cookie.trim().startsWith("uid="));
if (!uidCookie) {
// 어드민 사용자 정보 가져오기
fetch("/api/auth/session")
.then((res) => res.json())
.then((data) => {
if (!data.ok || !data.user) {
// 어드민으로 로그인 시도
fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: "admin", password: "1234" }),
})
.then((res) => res.json())
.then((loginData) => {
if (loginData.ok) {
// 페이지 새로고침하여 적용
window.location.reload();
}
})
.catch(() => {
// 에러 무시
});
}
})
.catch(() => {
// 에러 무시
});
}
};
checkCookie();
}, []);
return null;
}

View File

@@ -0,0 +1,365 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { RankIcon1st } from "./RankIcon1st";
import { RankIcon2nd } from "./RankIcon2nd";
import { RankIcon3rd } from "./RankIcon3rd";
import { UserAvatar } from "./UserAvatar";
import { ImagePlaceholderIcon } from "./ImagePlaceholderIcon";
import { PostList } from "./PostList";
import { GradeIcon } from "./GradeIcon";
type BoardMeta = {
id: string;
name: string;
slug: string;
mainTypeKey?: string;
};
type UserData = {
userId: string;
nickname: string | null;
points: number;
profileImage: string | null;
grade: number;
};
type PostData = {
id: string;
title: string;
createdAt: Date;
content?: string | null;
attachments?: { url: string }[];
stat?: { recommendCount: number | null; commentsCount: number | null };
};
type BoardPanelData = {
board: BoardMeta;
categoryName: string;
siblingBoards: BoardMeta[];
specialRankUsers?: UserData[];
previewPosts?: PostData[];
textPosts?: PostData[];
};
export function BoardPanelClient({
initialBoardId,
boardsData
}: {
initialBoardId: string;
boardsData: BoardPanelData[];
}) {
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;
const isNewWithin1Hour = (createdAt: Date | string | number | null | undefined): boolean => {
if (!createdAt) return false;
const t = new Date(createdAt).getTime();
if (Number.isNaN(t)) return false;
return (Date.now() - t) <= 60 * 60 * 1000;
};
function formatDateYmd(d: Date) {
const date = new Date(d);
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
return `${yyyy}.${mm}.${dd}`;
}
function stripHtml(html: string | null | undefined): string {
if (!html) return "";
// HTML 태그 제거
return html.replace(/<[^>]*>/g, "").trim();
}
function extractImageFromContent(content: string | null | undefined): string | null {
if (!content) return null;
// img 태그에서 src 속성 추출
const imgMatch = content.match(/<img[^>]+src=["']([^"']+)["'][^>]*>/i);
if (imgMatch && imgMatch[1]) {
return imgMatch[1];
}
// figure 안의 img 태그도 확인
const figureMatch = content.match(/<figure[^>]*>[\s\S]*?<img[^>]+src=["']([^"']+)["'][^>]*>/i);
if (figureMatch && figureMatch[1]) {
return figureMatch[1];
}
return null;
}
const isTextMain = board.mainTypeKey === "main_text";
const isSpecialRank = board.mainTypeKey === "main_special_rank";
const isPreview = board.mainTypeKey === "main_preview";
// 특수 랭킹 타입 렌더링
if (isSpecialRank && selectedBoardData.specialRankUsers) {
return (
<div className="h-full min-h-0 flex flex-col">
<div className="content-stretch flex flex-col md:flex-row gap-[8px] md:gap-[16px] 2xl:gap-[30px] items-start w-full mb-2">
<div className="flex items-center gap-[8px] shrink-0">
<div className="text-[20px] leading-[20px] md:text-[24px] md:leading-[24px] 2xl:text-[30px] 2xl:leading-[30px] text-[#5c5c5c]">{categoryName || board.name}</div>
<Link href={`/boards/${board.slug}`} aria-label={`${board.name} 게시판으로 이동`} className="shrink-0 group">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 19 19" fill="none" className="block group-hover:hidden">
<path d="M10.25 12.5L13.25 9.5M13.25 9.5L10.25 6.5M13.25 9.5L5.75 9.5M18.5 9.5C18.5 14.4706 14.4706 18.5 9.5 18.5C4.52944 18.5 0.5 14.4706 0.5 9.5C0.5 4.52944 4.52944 0.5 9.5 0.5C14.4706 0.5 18.5 4.52944 18.5 9.5Z" stroke="#8C8C8C" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" className="hidden group-hover:block">
<path fillRule="evenodd" clipRule="evenodd" d="M9.75 0C4.36522 0 0 4.36522 0 9.75C0 15.1348 4.36522 19.5 9.75 19.5C15.1348 19.5 19.5 15.1348 19.5 9.75C19.5 4.36522 15.1348 0 9.75 0ZM14.0303 10.2803C14.171 10.1397 14.25 9.94891 14.25 9.75C14.25 9.55109 14.171 9.36032 14.0303 9.21967L11.0303 6.21967C10.7374 5.92678 10.2626 5.92678 9.96967 6.21967C9.67678 6.51256 9.67678 6.98744 9.96967 7.28033L11.6893 9L6 9C5.58579 9 5.25 9.33578 5.25 9.75C5.25 10.1642 5.58579 10.5 6 10.5L11.6893 10.5L9.96967 12.2197C9.67678 12.5126 9.67678 12.9874 9.96967 13.2803C10.2626 13.5732 10.7374 13.5732 11.0303 13.2803L14.0303 10.2803Z" fill="#5C5C5C" />
</svg>
</Link>
</div>
<div className="flex items-center gap-[6px] md:gap-[6px] 2xl:gap-[8px] flex-wrap w-full md:w-auto min-w-0 md:flex-1 md:overflow-x-auto md:flex-nowrap no-scrollbar">
{siblingBoards.map((sb) => (
<button
key={sb.id}
onClick={() => setSelectedBoardId(sb.id)}
className={`px-3 py-1.5 text-[12px] rounded-[12px] md:px-3.5 md:py-[6px] md:text-[13px] md:rounded-[12px] 2xl:px-[16px] 2xl:py-[8px] 2xl:text-[14px] 2xl:rounded-[14px] shrink-0 cursor-pointer ${sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5] hover:bg-[#5c5c5c] hover:text-white hover:border-[#5c5c5c] transition-colors"
}`}
>
{sb.name}
</button>
))}
</div>
</div>
<div className="overflow-hidden h-full min-h-0 flex flex-col">
<div className="flex-1 min-h-0 overflow-hidden p-0">
<div className="px-[0px] pt-[6px] pb-[6px]">
<div className="flex flex-col gap-[6px]">
{selectedBoardData.specialRankUsers.map((user, idx) => {
const rank = idx + 1;
return (
<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">
<div className="flex flex-col gap-[4px] min-w-0 flex-1">
<div className="flex items-center gap-[6px]">
{
(rank === 1 || rank === 2 || rank === 3) && (
<div className="relative w-[22px] h-[22px] shrink-0">
{rank === 1 && <RankIcon1st />}
{rank === 2 && <RankIcon2nd />}
{rank === 3 && <RankIcon3rd />}
</div>
)
}
<div className="bg-white border border-[#d5d5d5] px-[8px] py-[2px] rounded-[8px] text-[11px] text-[#5c5c5c] shrink-0">
{rank}
</div>
</div>
<div className="text-[13px] font-medium text-[#5c5c5c] truncate leading-[16px]">
{user.nickname || "익명"}
</div>
</div>
<div className="h-[16px] flex items-center gap-[4px] shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" className="shrink-0">
<path d="M8.17377 0.0481988C8.1834 0.0326271 8.19596 0.0201252 8.21037 0.0117499C8.22478 0.00337451 8.24062 -0.000628204 8.25656 7.99922e-05C8.2725 0.000788189 8.28806 0.0061865 8.30194 0.0158187C8.31582 0.0254509 8.3276 0.0390338 8.33629 0.0554196L11.3323 5.55973C11.3575 5.60512 11.4038 5.62472 11.4468 5.60718L15.8683 3.80198C15.9441 3.77104 16.0174 3.85666 15.9963 3.95053L14.2584 16H1.74226L0.00344328 3.95156C-0.00125237 3.93019 -0.0011435 3.90765 0.00375838 3.88635C0.00866025 3.86505 0.0181727 3.84576 0.0312889 3.83054C0.0444051 3.81532 0.0606369 3.80472 0.0782661 3.79988C0.0958953 3.79503 0.114266 3.79612 0.131434 3.80302L4.55889 5.61131C4.59846 5.62678 4.64309 5.61131 4.66835 5.57005L8.17461 0.0481988H8.17377Z" fill="#FFB625" />
</svg>
<span className="text-[12px] font-semibold text-[#5c5c5c] leading-[16px]">{user.points.toLocaleString()}</span>
</div>
</div>
</Link>
);
})}
</div>
</div>
</div>
</div>
</div>
);
}
// 미리보기 타입 렌더링
if (isPreview && selectedBoardData.previewPosts) {
return (
<div className="h-full min-h-0 flex flex-col">
<div className="content-stretch flex flex-col md:flex-row gap-[8px] md:gap-[16px] lg:gap-[30px] items-start w-full mb-2">
<div className="flex items-center gap-[8px] shrink-0">
<div className="text-[20px] leading-[20px] md:text-[24px] md:leading-[24px] lg:text-[30px] lg:leading-[30px] text-[#5c5c5c]">{categoryName || board.name}</div>
<Link href={`/boards/${board.slug}`} aria-label={`${board.name} 게시판으로 이동`} className="shrink-0 group">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 19 19" fill="none" className="block group-hover:hidden">
<path d="M10.25 12.5L13.25 9.5M13.25 9.5L10.25 6.5M13.25 9.5L5.75 9.5M18.5 9.5C18.5 14.4706 14.4706 18.5 9.5 18.5C4.52944 18.5 0.5 14.4706 0.5 9.5C0.5 4.52944 4.52944 0.5 9.5 0.5C14.4706 0.5 18.5 4.52944 18.5 9.5Z" stroke="#8C8C8C" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" className="hidden group-hover:block">
<path fillRule="evenodd" clipRule="evenodd" d="M9.75 0C4.36522 0 0 4.36522 0 9.75C0 15.1348 4.36522 19.5 9.75 19.5C15.1348 19.5 19.5 15.1348 19.5 9.75C19.5 4.36522 15.1348 0 9.75 0ZM14.0303 10.2803C14.171 10.1397 14.25 9.94891 14.25 9.75C14.25 9.55109 14.171 9.36032 14.0303 9.21967L11.0303 6.21967C10.7374 5.92678 10.2626 5.92678 9.96967 6.21967C9.67678 6.51256 9.67678 6.98744 9.96967 7.28033L11.6893 9L6 9C5.58579 9 5.25 9.33578 5.25 9.75C5.25 10.1642 5.58579 10.5 6 10.5L11.6893 10.5L9.96967 12.2197C9.67678 12.5126 9.67678 12.9874 9.96967 13.2803C10.2626 13.5732 10.7374 13.5732 11.0303 13.2803L14.0303 10.2803Z" fill="#5C5C5C" />
</svg>
</Link>
</div>
<div className="flex items-center gap-[6px] md:gap-[6px] lg:gap-[8px] flex-wrap w-full md:w-auto min-w-0 md:flex-1 md:overflow-x-auto md:flex-nowrap no-scrollbar">
{siblingBoards.map((sb) => (
<button
key={sb.id}
onClick={() => setSelectedBoardId(sb.id)}
className={`px-3 py-1.5 text-[12px] rounded-[12px] md:px-3.5 md:py-[6px] md:text-[13px] md:rounded-[12px] lg:px-[16px] lg:py-[8px] lg:text-[14px] lg:rounded-[14px] shrink-0 cursor-pointer ${sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5] hover:bg-[#5c5c5c] hover:text-white hover:border-[#5c5c5c] transition-colors"
}`}
>
{sb.name}
</button>
))}
</div>
</div>
<div className=" overflow-hidden h-full min-h-0 flex flex-col">
<div className="flex-1 min-h-0 overflow-hidden p-0">
<div className="px-[0px] pt-[6px] pb-[6px]">
<div className="flex flex-col gap-[6px]">
{selectedBoardData.previewPosts.map((post) => {
// attachments에서 이미지를 먼저 찾고, 없으면 content에서 추출
const firstImage = post.attachments?.[0]?.url || extractImageFromContent(post.content);
return (
<Link key={post.id} href={`/posts/${post.id}`} className="mx-[4px] group flex h-[72px] items-start 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-[#ededed] overflow-hidden">
{firstImage ? (
<img
src={firstImage}
alt={stripHtml(post.title)}
className="w-full h-full object-cover"
/>
) : (
<div className="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] flex items-center justify-center">
<ImagePlaceholderIcon width={32} height={32} />
</div>
)}
</div>
<div className="flex-1 flex items-center gap-[6px] px-[10px] md:px-[10px] py-[6px] min-w-0">
<div className="flex flex-col gap-[4px] min-w-0 flex-1">
<div className="bg-white border border-[#d5d5d5] px-[8px] py-[2px] rounded-[8px] text-[11px] text-[#5c5c5c] shrink-0 w-fit">
{board.name}
</div>
<div className="flex items-center gap-[4px] overflow-hidden">
{isNewWithin1Hour(post.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">
<path fillRule="evenodd" clipRule="evenodd" d="M1 8C1 4.5691 4.26184 2 8 2C11.7382 2 15 4.5691 15 8C15 11.4309 11.7382 14 8 14C7.57698 14 7.16215 13.9679 6.7588 13.9061C5.85852 14.4801 4.81757 14.8544 3.6995 14.9654C3.41604 14.9936 3.1411 14.8587 2.98983 14.6174C2.83857 14.376 2.83711 14.0698 2.98605 13.8269C3.21838 13.4482 3.38055 13.0221 3.45459 12.5659C1.97915 11.4858 1 9.86014 1 8Z" fill="#F45F00" />
</svg>
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
</div>
)}
<span className="text-[13px] leading-[18px] text-[#5c5c5c] truncate group-hover:text-[var(--red-50,#F94B37)] group-hover:font-[700]" style={{ fontFamily: "var(--font-family-Font-1, Pretendard)" }}>{stripHtml(post.title)}</span>
{(post.stat?.commentsCount ?? 0) > 0 && (
<span className="ml-1 text-[11px] text-[#f45f00] font-bold shrink-0">[{post.stat?.commentsCount}]</span>
)}
</div>
<div className="h-[16px] relative">
<span className="absolute top-1/2 translate-y-[-50%] text-[10px] leading-[10px] tracking-[-0.24px] text-[#8c8c8c]">
{formatDateYmd(post.createdAt)}
</span>
</div>
</div>
</div>
</Link>
);
})}
</div>
</div>
</div>
</div>
</div>
);
}
// 텍스트 메인 타입 또는 기본 타입 렌더링
return (
<div className="h-full min-h-0 flex flex-col">
<div className="content-stretch flex flex-col md:flex-row gap-[8px] md:gap-[16px] 2xl:gap-[30px] items-start w-full mb-2">
<div className="flex items-center gap-[8px] shrink-0">
<div className="text-[20px] leading-[20px] md:text-[24px] md:leading-[24px] 2xl:text-[30px] 2xl:leading-[30px] text-[#5c5c5c]">{categoryName || board.name}</div>
<Link href={`/boards/${board.slug}`} aria-label={`${board.name} 게시판으로 이동`} className="shrink-0 group">
{/* 기본 아이콘 */}
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 19 19" fill="none" className="block group-hover:hidden">
<path d="M10.25 12.5L13.25 9.5M13.25 9.5L10.25 6.5M13.25 9.5L5.75 9.5M18.5 9.5C18.5 14.4706 14.4706 18.5 9.5 18.5C4.52944 18.5 0.5 14.4706 0.5 9.5C0.5 4.52944 4.52944 0.5 9.5 0.5C14.4706 0.5 18.5 4.52944 18.5 9.5Z" stroke="#8C8C8C" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{/* 호버 아이콘 */}
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" className="hidden group-hover:block">
<path fillRule="evenodd" clipRule="evenodd" d="M9.75 0C4.36522 0 0 4.36522 0 9.75C0 15.1348 4.36522 19.5 9.75 19.5C15.1348 19.5 19.5 15.1348 19.5 9.75C19.5 4.36522 15.1348 0 9.75 0ZM14.0303 10.2803C14.171 10.1397 14.25 9.94891 14.25 9.75C14.25 9.55109 14.171 9.36032 14.0303 9.21967L11.0303 6.21967C10.7374 5.92678 10.2626 5.92678 9.96967 6.21967C9.67678 6.51256 9.67678 6.98744 9.96967 7.28033L11.6893 9L6 9C5.58579 9 5.25 9.33578 5.25 9.75C5.25 10.1642 5.58579 10.5 6 10.5L11.6893 10.5L9.96967 12.2197C9.67678 12.5126 9.67678 12.9874 9.96967 13.2803C10.2626 13.5732 10.7374 13.5732 11.0303 13.2803L14.0303 10.2803Z" fill="#5C5C5C" />
</svg>
</Link>
</div>
<div className="flex items-center gap-[6px] md:gap-[6px] 2xl:gap-[8px] flex-wrap w-full md:w-auto min-w-0 md:flex-1 md:overflow-x-auto md:flex-nowrap no-scrollbar">
{siblingBoards.map((sb) => (
<button
key={sb.id}
onClick={() => setSelectedBoardId(sb.id)}
className={`px-3 py-1.5 text-[12px] rounded-[12px] md:px-3.5 md:py-[6px] md:text-[13px] md:rounded-[12px] 2xl:px-[16px] 2xl:py-[8px] 2xl:text-[14px] 2xl:rounded-[14px] shrink-0 cursor-pointer ${sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5] hover:bg-[#5c5c5c] hover:text-white hover:border-[#5c5c5c] transition-colors"
}`}
>
{sb.name}
</button>
))}
</div>
</div>
<div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col bg-white">
{!isTextMain && (
<div className="px-3 py-2 border-b border-neutral-200 flex items-center justify-between">
<Link href={`/boards/${board.slug}`} className="text-lg md:text-xl font-bold text-neutral-800 truncate">{board.name}</Link>
<Link href={`/boards/${board.slug}`} className="text-xs px-3 py-1 rounded-full border border-neutral-300 text-neutral-700 hover:bg-neutral-100"></Link>
</div>
)}
<div className="flex-1 min-h-0 overflow-hidden p-0">
{isTextMain && selectedBoardData.textPosts ? (
<div className="bg-white px-[24px] pt-[8px] pb-[16px]">
<ul className="min-h-[326px]">
{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-[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">
<path fillRule="evenodd" clipRule="evenodd" d="M1 8C1 4.5691 4.26184 2 8 2C11.7382 2 15 4.5691 15 8C15 11.4309 11.7382 14 8 14C7.57698 14 7.16215 13.9679 6.7588 13.9061C5.85852 14.4801 4.81757 14.8544 3.6995 14.9654C3.41604 14.9936 3.1411 14.8587 2.98983 14.6174C2.83857 14.376 2.83711 14.0698 2.98605 13.8269C3.21838 13.4482 3.38055 13.0221 3.45459 12.5659C1.97915 11.4858 1 9.86014 1 8Z" fill="#F45F00" />
</svg>
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
</div>
)}
<span className="text-[14px] leading-[20px] text-[#5c5c5c] truncate max-w-[calc(100vw-280px)] 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="text-[14px] text-[#f45f00] font-bold">[{p.stat?.commentsCount}]</span>
)}
</Link>
<span className="text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]">{formatDateYmd(p.createdAt)}</span>
</div>
</li>
))}
</ul>
</div>
) : (
<PostList key={board.id} boardId={board.id} sort="recent" titleHoverOrange pageSizeOverride={16} compact />
)}
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More