Compare commits
47 Commits
2047e044d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afc714022f | ||
|
|
c85450ce37 | ||
|
|
c5bc8f5b49 | ||
|
|
14c80baeec | ||
|
|
5287611bf7 | ||
|
|
cb2d1f34d3 | ||
|
|
97c8e1c9fb | ||
|
|
5485da4029 | ||
|
|
b579b32138 | ||
|
|
4337a8f69a | ||
|
|
a007ac11ce | ||
|
|
34e831f738 | ||
|
|
cfbb3d50ee | ||
|
|
1c2222da67 | ||
|
|
bb71b892ca | ||
|
|
ab81a3da3d | ||
|
|
5f72e6ce7c | ||
|
|
1ec2df27b0 | ||
|
|
1fb859fdf9 | ||
|
|
e91085b4ed | ||
|
|
2d722a89c7 | ||
|
|
c348fb55fa | ||
|
|
c7d5679788 | ||
|
|
a646dfd09f | ||
|
|
4d1e7343c3 | ||
|
|
808fe5fc68 | ||
|
|
7d9c241d17 | ||
|
|
5b8749d11f | ||
|
|
e518c988b2 | ||
|
|
12044587c9 | ||
| ecf2dab35c | |||
| 1016d8717c | |||
| e7dd2cca00 | |||
| 34f9f0cb32 | |||
| 30c66158d5 | |||
| 69c54fad1d | |||
| c20599164e | |||
| 664c72fa41 | |||
| 486a18451b | |||
|
|
9bfbafcec1 | ||
|
|
2c9898be90 | ||
| 34b2739405 | |||
| 4aaa542ded | |||
| 1049d029b4 | |||
|
|
f4e46c39fb | ||
|
|
fadd402e63 | ||
|
|
b10d41532b |
37
.cursor/.prompt/new.md
Normal file
@@ -0,0 +1,37 @@
|
||||
7.게시판 열 구분선 잘보이게 요청 / 모바일 최적화 요청 및 구분선,높이 조절 요청
|
||||
8.글삭제기능 어드민추가
|
||||
|
||||
9-1.시크로드 참고 - 제휴업체 리스트, 지역별 / 제휴업체 정보 생성 요청 (제휴업체 프로필,출근부)
|
||||
9-2.제휴업체 및 프로필 등록은, 권한을부여한 제휴업체가 직접 등록하도록 해야합니다
|
||||
9-3.제휴업체 등록 주인이하면안됨, 시크로드 - 제휴문의 - 제휴문의글쓰기 참고요청
|
||||
제휴문의 게시판 ( 관리자 승인후) → 이동
|
||||
제휴업소 리스트 게시판 (노출됨)"
|
||||
|
||||
10.배너관리 이미지 사이즈 규격 및 제휴업체 등록시 이미지 사이즈 규격
|
||||
11,게시판, 주간인기글 , 일간인기글 추가 요청
|
||||
|
||||
12.https://search.google.com/search-console/welcome 등록요청
|
||||
13.메인페이지 제휴업체 배너 이미지 규격작게 수정요청, 한번에 4개이상 정도 보이는 정도
|
||||
14.메인페이지 제휴업체 클릭시 제휴업체 카테고리 하이퍼연결 추가 요청
|
||||
15.레벨별 아이콘 페이지 / https://seekrod.co.kr/bbs/page.php?hid=point 참고
|
||||
16. 게시글 상단고정 기능
|
||||
17. 메인화면 큰 카테고리 빼기
|
||||
18. google SEO: header, meta, description 동적설정
|
||||
2. 회색 배경 영역(비밀글, 글자수)이 글 작성 영역 안에 있어야 함
|
||||
로그인 프로세스 수정: 로그인시 로그인 팝업 모달
|
||||
로그인 로그오프 시, 메인 프로필 디자인 누락되서 추가(지금상태로 가도 무상관)
|
||||
1. 게시글에서 목록돌아가는 버튼 디자인과 다름
|
||||
2. 게시글에서 제목, 내용, 댓글 디자인 다름
|
||||
3. 게시글에서 최하단에 게시글리스트 부분 디자인 다
|
||||
4. 게시글 리스트에서 리스트버튼 하단 divider 색상이 너무 연함
|
||||
5. 표시개수 필요없을 거 같은데, 그냥 고정해버리죠?
|
||||
|
||||
9. 게시글 이미지 배치사이즈
|
||||
10. 외부접속 가입없어도 가능
|
||||
11. 글뷰에서 게시글 리스트로
|
||||
12. 게시글 리스트 디자인
|
||||
13. 포인트 규칙
|
||||
14. 게시판권한 확인
|
||||
15.메인뷰 3열 사이즈 변경
|
||||
16. 로그인 안됐을때 카드 휑함
|
||||
17.이미지 사이즈 미리불러오기
|
||||
36
.gitea/workflows/deploy.yml
Normal 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
@@ -46,3 +46,4 @@ next-env.d.ts
|
||||
/prisma/prisma/dev.db
|
||||
|
||||
*.ignore
|
||||
/logs
|
||||
|
||||
13
.runner
Normal 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
@@ -0,0 +1,6 @@
|
||||
test
|
||||
new address check
|
||||
new address check2
|
||||
deploy test2dfdf213
|
||||
deploy test001
|
||||
deploy test002
|
||||
18
ecosystem.config.js
Normal 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
@@ -0,0 +1,3 @@
|
||||
export { middleware, config } from "./src/middleware";
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
@@ -332,11 +336,14 @@ model Comment {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
|
||||
reports Report[]
|
||||
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로 통합)
|
||||
|
||||
304
prisma/seed.js
@@ -37,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" } });
|
||||
// 사용되지 않은 전화번호를 찾는 보조 함수
|
||||
@@ -50,22 +119,36 @@ async function createRandomUsers(count = 100) {
|
||||
}
|
||||
}
|
||||
|
||||
const createdUsers = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
// 닉네임을 결정론적으로 생성해 재실행 시 중복 생성 방지
|
||||
const nickname = `user${String(i + 1).padStart(3, "0")}`;
|
||||
const existing = await prisma.user.findUnique({ where: { nickname } });
|
||||
// 고정 ID: user001, user002, ...
|
||||
const userId = `user${String(i + 1).padStart(3, "0")}`;
|
||||
const existing = await prisma.user.findUnique({ where: { userId } });
|
||||
let user = existing;
|
||||
if (!existing) {
|
||||
const name = generateRandomKoreanName();
|
||||
const birth = randomDate(1975, 2005);
|
||||
const phone = await findAvailablePhone(i + 2); // admin이 0001 사용하므로 겹치지 않도록 오프셋
|
||||
// 닉네임: 중복 없는 랜덤 한글 생성
|
||||
let nickname = generateRandomKoreanName();
|
||||
for (let tries = 0; tries < 10; tries++) {
|
||||
const dup = await prisma.user.findUnique({ where: { nickname } });
|
||||
if (!dup) break;
|
||||
nickname = generateRandomKoreanName();
|
||||
}
|
||||
// 그래도 중복이면 희귀 조합 한 번 더 시도
|
||||
const finalDup = await prisma.user.findUnique({ where: { nickname } });
|
||||
if (finalDup) {
|
||||
nickname = generateRandomKoreanName();
|
||||
}
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
userId,
|
||||
nickname,
|
||||
name,
|
||||
birth,
|
||||
phone,
|
||||
passwordHash: hashPassword("1234"),
|
||||
passwordHash: hashPassword("12341234"),
|
||||
agreementTermsAt: new Date(),
|
||||
authLevel: "USER",
|
||||
isAdultVerified: Math.random() < 0.6,
|
||||
@@ -76,7 +159,7 @@ async function createRandomUsers(count = 100) {
|
||||
// 기존 사용자도 패스워드를 1234로 업데이트
|
||||
await prisma.user.update({
|
||||
where: { userId: user.userId },
|
||||
data: { passwordHash: hashPassword("1234") },
|
||||
data: { passwordHash: hashPassword("12341234") },
|
||||
});
|
||||
}
|
||||
if (roleUser && user) {
|
||||
@@ -86,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() {
|
||||
@@ -95,7 +180,6 @@ async function upsertCategories() {
|
||||
{ name: "메인", slug: "main", sortOrder: 1, status: "active" },
|
||||
{ name: "명예의 전당", slug: "hall-of-fame", sortOrder: 2, status: "active" },
|
||||
{ name: "소통방", slug: "community", sortOrder: 3, status: "active" },
|
||||
{ name: "TEST", slug: "test", sortOrder: 4, status: "active" },
|
||||
];
|
||||
const map = {};
|
||||
for (const c of categories) {
|
||||
@@ -168,15 +252,24 @@ async function upsertRoles() {
|
||||
async function upsertAdmin() {
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { nickname: "admin" },
|
||||
update: { passwordHash: hashPassword("1234") },
|
||||
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("1234"),
|
||||
passwordHash: hashPassword("12341234"),
|
||||
agreementTermsAt: new Date(),
|
||||
authLevel: "ADMIN",
|
||||
grade: 7,
|
||||
points: 1650000,
|
||||
level: 200,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -193,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 = [
|
||||
// 일반
|
||||
@@ -209,17 +351,16 @@ async function upsertBoards(admin, categoryMap) {
|
||||
{ name: "회원랭킹", slug: "ranking", description: "랭킹", sortOrder: 14 },
|
||||
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", sortOrder: 15 },
|
||||
{ name: "월간집계", slug: "monthly-stats", description: "월간 통계", sortOrder: 16 },
|
||||
// TEST
|
||||
{ name: "TEST", slug: "test", description: "테스트 게시판", sortOrder: 20 },
|
||||
// 광고/제휴 계열 게시판은 제거(메인/명예의전당/소통방 외)
|
||||
];
|
||||
|
||||
const created = [];
|
||||
// 특수 랭킹/텍스트/미리보기 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨)
|
||||
// 텍스트/특수랭킹/특수출석 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨)
|
||||
const mainText = await prisma.boardViewType.findUnique({ where: { key: "main_text" } });
|
||||
const listText = await prisma.boardViewType.findUnique({ where: { key: "list_text" } });
|
||||
const mainSpecial = await prisma.boardViewType.findUnique({ where: { key: "main_special_rank" } });
|
||||
const listSpecial = await prisma.boardViewType.findUnique({ where: { key: "list_special_rank" } });
|
||||
const mainText = await prisma.boardViewType.findUnique({ where: { key: "main_text" } });
|
||||
const mainPreview = await prisma.boardViewType.findUnique({ where: { key: "main_preview" } });
|
||||
const listSpecialAttendance = await prisma.boardViewType.findUnique({ where: { key: "list_special_attendance" } });
|
||||
|
||||
for (const b of boards) {
|
||||
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
|
||||
@@ -239,8 +380,6 @@ async function upsertBoards(admin, categoryMap) {
|
||||
qna: "community",
|
||||
tips: "community",
|
||||
anonymous: "community",
|
||||
// TEST
|
||||
test: "test",
|
||||
// 광고/제휴
|
||||
};
|
||||
const categorySlug = mapBySlug[b.slug] || "community";
|
||||
@@ -253,13 +392,12 @@ async function upsertBoards(admin, categoryMap) {
|
||||
allowAnonymousPost: !!b.allowAnonymousPost,
|
||||
readLevel: b.readLevel || undefined,
|
||||
categoryId: category ? category.id : undefined,
|
||||
// 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지, TEST는 미리보기
|
||||
...(b.slug === "ranking"
|
||||
? (mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {})
|
||||
: b.slug === "test"
|
||||
? (mainPreview ? { mainPageViewTypeId: mainPreview.id } : {})
|
||||
: (mainText ? { mainPageViewTypeId: mainText.id } : {})),
|
||||
// 기본은 텍스트, 'ranking'은 특수랭킹, 'attendance'는 특수출석으로 오버라이드
|
||||
...(mainText ? { mainPageViewTypeId: mainText.id } : {}),
|
||||
...(listText ? { listViewTypeId: listText.id } : {}),
|
||||
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
|
||||
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
||||
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
|
||||
},
|
||||
create: {
|
||||
name: b.name,
|
||||
@@ -269,13 +407,12 @@ async function upsertBoards(admin, categoryMap) {
|
||||
allowAnonymousPost: !!b.allowAnonymousPost,
|
||||
readLevel: b.readLevel || undefined,
|
||||
categoryId: category ? category.id : undefined,
|
||||
// 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지, TEST는 미리보기
|
||||
...(b.slug === "ranking"
|
||||
? (mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {})
|
||||
: b.slug === "test"
|
||||
? (mainPreview ? { mainPageViewTypeId: mainPreview.id } : {})
|
||||
: (mainText ? { mainPageViewTypeId: mainText.id } : {})),
|
||||
// 기본은 텍스트, 'ranking'은 특수랭킹, 'attendance'는 특수출석으로 오버라이드
|
||||
...(mainText ? { mainPageViewTypeId: mainText.id } : {}),
|
||||
...(listText ? { listViewTypeId: listText.id } : {}),
|
||||
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
|
||||
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
||||
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
|
||||
},
|
||||
});
|
||||
created.push(board);
|
||||
@@ -296,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({
|
||||
@@ -327,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,
|
||||
});
|
||||
@@ -381,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 },
|
||||
});
|
||||
}
|
||||
// 표시 토글 기본값 보장
|
||||
@@ -416,7 +559,7 @@ async function seedMainpageVisibleBoards(boards) {
|
||||
const SETTINGS_KEY = "mainpage_settings";
|
||||
const setting = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
|
||||
const current = setting ? JSON.parse(setting.value) : {};
|
||||
const wantSlugs = new Set(["notice", "free", "ranking", "test"]);
|
||||
const wantSlugs = new Set(["notice", "free", "ranking"]);
|
||||
const visibleBoardIds = boards.filter((b) => wantSlugs.has(b.slug)).map((b) => b.id);
|
||||
const next = { ...current, visibleBoardIds };
|
||||
await prisma.setting.upsert({
|
||||
@@ -427,13 +570,80 @@ async function seedMainpageVisibleBoards(boards) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("DATABASE_URL:", process.env.DATABASE_URL);
|
||||
try {
|
||||
const tables = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table'`;
|
||||
console.log("SQLite tables:", tables.map((t) => t.name || t.NAME || JSON.stringify(t)));
|
||||
} catch {}
|
||||
|
||||
// SQLite 수동 보정: partner_categories 테이블과 partners.categoryId 컬럼 보장
|
||||
try {
|
||||
const rows = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table' AND name='partner_categories'`;
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
console.log("Creating missing table: partner_categories");
|
||||
await prisma.$executeRawUnsafe(
|
||||
"CREATE TABLE IF NOT EXISTS partner_categories (\n" +
|
||||
"id TEXT PRIMARY KEY,\n" +
|
||||
"name TEXT NOT NULL UNIQUE,\n" +
|
||||
"sortOrder INTEGER NOT NULL DEFAULT 0,\n" +
|
||||
"createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n" +
|
||||
")"
|
||||
);
|
||||
await prisma.$executeRawUnsafe("CREATE INDEX IF NOT EXISTS idx_partner_categories_sortOrder ON partner_categories(sortOrder)");
|
||||
}
|
||||
// Attendance 테이블 보장 (마이그레이션 미실행 환경 대응)
|
||||
const att = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table' AND name='attendance'`;
|
||||
if (!Array.isArray(att) || att.length === 0) {
|
||||
console.log("Creating missing table: attendance");
|
||||
await prisma.$executeRawUnsafe(
|
||||
"CREATE TABLE IF NOT EXISTS attendance (\n" +
|
||||
"id TEXT PRIMARY KEY,\n" +
|
||||
"userId TEXT NOT NULL,\n" +
|
||||
"date DATETIME NOT NULL,\n" +
|
||||
"createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n" +
|
||||
")"
|
||||
);
|
||||
await prisma.$executeRawUnsafe("CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_user_date ON attendance(userId, date)");
|
||||
await prisma.$executeRawUnsafe("CREATE INDEX IF NOT EXISTS idx_attendance_date ON attendance(date)");
|
||||
}
|
||||
const cols = await prisma.$queryRaw`PRAGMA table_info('partners')`;
|
||||
const hasCategoryId = Array.isArray(cols) && cols.some((c) => (c.name || c.COLUMN_NAME) === 'categoryId');
|
||||
if (!hasCategoryId) {
|
||||
console.log("Adding missing column: partners.categoryId");
|
||||
await prisma.$executeRawUnsafe("ALTER TABLE partners ADD COLUMN categoryId TEXT");
|
||||
// 외래키 제약은 생략 (SQLite에서는 제약 추가가 까다로움)
|
||||
}
|
||||
// partner_requests 확장 컬럼 보장 (region, imageUrl, sortOrder, active)
|
||||
const prCols = await prisma.$queryRaw`PRAGMA table_info('partner_requests')`;
|
||||
const colHas = (name) => Array.isArray(prCols) && prCols.some((c) => (c.name || c.COLUMN_NAME) === name);
|
||||
if (!colHas('region')) {
|
||||
console.log("Adding missing column: partner_requests.region");
|
||||
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN region TEXT");
|
||||
}
|
||||
if (!colHas('imageUrl')) {
|
||||
console.log("Adding missing column: partner_requests.imageUrl");
|
||||
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN imageUrl TEXT");
|
||||
}
|
||||
if (!colHas('sortOrder')) {
|
||||
console.log("Adding missing column: partner_requests.sortOrder");
|
||||
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN sortOrder INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
if (!colHas('active')) {
|
||||
console.log("Adding missing column: partner_requests.active");
|
||||
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN active BOOLEAN NOT NULL DEFAULT 1");
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("SQLite schema ensure failed:", e);
|
||||
}
|
||||
await upsertRoles();
|
||||
const admin = await upsertAdmin();
|
||||
const categoryMap = await upsertCategories();
|
||||
await upsertViewTypes();
|
||||
await createRandomUsers(100);
|
||||
const randomUsers = await createRandomUsers(3);
|
||||
await seedRandomAttendanceForUsers(randomUsers, 10, 20);
|
||||
await removeNonPrimaryBoards();
|
||||
const boards = await upsertBoards(admin, categoryMap);
|
||||
await seedAdminAttendance(admin);
|
||||
await seedMainpageVisibleBoards(boards);
|
||||
await createPostsForAllBoards(boards, 100, admin);
|
||||
await seedPartnerShops();
|
||||
@@ -446,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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
9
public/svgs/01_bronze.svg
Normal file
|
After Width: | Height: | Size: 99 KiB |
9
public/svgs/02_silver.svg.svg
Normal file
|
After Width: | Height: | Size: 69 KiB |
9
public/svgs/03_gold.svg
Normal file
|
After Width: | Height: | Size: 70 KiB |
9
public/svgs/04_platinum.svg
Normal file
|
After Width: | Height: | Size: 84 KiB |
9
public/svgs/05_diamond.svg
Normal file
|
After Width: | Height: | Size: 92 KiB |
9
public/svgs/06_master.svg
Normal file
|
After Width: | Height: | Size: 96 KiB |
9
public/svgs/07_grandmaster.svg
Normal file
|
After Width: | Height: | Size: 95 KiB |
9
public/svgs/08_god.svg
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
public/uploads/1762052938422-has0h33j4x6.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/uploads/1762053004600-0ewlk5af03i.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/uploads/1762059929501-cxe093gyep7.webp
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
public/uploads/1762060014485-401jthedn6x.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/uploads/1762060051441-h7kz3p9myc6.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762060249110-ut9vnyatzc.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/uploads/1762060270463-di302vcwbg.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
public/uploads/1762060431071-kpio217ffh7.webp
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
public/uploads/1762349790327-56eucbsdkiy.webp
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
public/uploads/1762349798482-lg2199h4w0h.webp
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
public/uploads/1762439529544-pfdpsiv372l.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/uploads/1762439795788-41zbv74p6l9.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762444179265-fuj8zoahblc.jpg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
public/uploads/1762520156525-1dqijvt0rge.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/uploads/1762521903585-d2gxpaoocil.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/uploads/1762694665640-mepawpoqguh.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762694722874-13r02smuxh0n.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762694815229-575v2kyj72x.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762695071878-s0a8nautp7d.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762695378037-rbj4gzlxveq.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762696083342-gwebeuwl0q4.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/uploads/1762696149731-1fom3wudm94.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/uploads/1762701326416-gknp8r0e4af.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762702099453-si2e8ubylu9.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/uploads/1762703335687-i85lpr0bgo.webp
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/uploads/1762704770941-j2nzhl8ww1.webp
Normal file
|
After Width: | Height: | Size: 279 KiB |
@@ -15,7 +15,7 @@ const navItems = [
|
||||
export default function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<aside className="w-56 shrink-0 border-r border-neutral-200 bg-white/80 backdrop-blur h-full">
|
||||
<aside className="w-56 shrink-0 border-r border-neutral-200 bg-white/90 backdrop-blur h-full">
|
||||
<div className="px-4 py-4 border-b border-neutral-200">
|
||||
<Link href="/admin" className="block text-lg font-bold text-neutral-900">관리자</Link>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,8 @@ export default function AdminBoardsPage() {
|
||||
const listTypes = useMemo(() => viewTypes.filter((v: any) => v.scope === 'list'), [viewTypes]);
|
||||
const defaultMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_default' || t.key === 'default')?.id ?? null), [mainTypes]);
|
||||
const defaultListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_default' || t.key === 'default')?.id ?? null), [listTypes]);
|
||||
const textMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_text')?.id ?? null), [mainTypes]);
|
||||
const textListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_text')?.id ?? null), [listTypes]);
|
||||
const categories = useMemo(() => {
|
||||
const merged = rawCategories.map((c: any) => ({ ...c, ...(dirtyCats[c.id] ?? {}) }));
|
||||
return merged.sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
@@ -237,7 +239,7 @@ export default function AdminBoardsPage() {
|
||||
return;
|
||||
}
|
||||
const sortOrder = (currentItems?.length ?? 0) + 1;
|
||||
await fetch(`/api/admin/boards`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, categoryId: catId === "uncat" ? null : catId, sortOrder, status: "active", mainPageViewTypeId: defaultMainTypeId, listViewTypeId: defaultListTypeId }) });
|
||||
await fetch(`/api/admin/boards`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, categoryId: catId === "uncat" ? null : catId, sortOrder, status: "active", mainPageViewTypeId: textMainTypeId, listViewTypeId: textListTypeId }) });
|
||||
await mutateBoards();
|
||||
}
|
||||
|
||||
@@ -436,12 +438,20 @@ export default function AdminBoardsPage() {
|
||||
|
||||
function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, mainTypes, listTypes, onAddType, defaultMainTypeId, defaultListTypeId }: { b: any; onDirty: (id: string, draft: any) => void; onDelete: () => void; allowMove?: boolean; categories?: any[]; onMove?: (toId: string) => void; mainTypes?: any[]; listTypes?: any[]; onAddType?: (scope: 'main'|'list') => Promise<string | null>; defaultMainTypeId?: string | null; defaultListTypeId?: string | null }) {
|
||||
const [edit, setEdit] = useState(b);
|
||||
const effectiveMainTypeId = edit.mainPageViewTypeId ?? defaultMainTypeId ?? '';
|
||||
const effectiveListTypeId = edit.listViewTypeId ?? defaultListTypeId ?? '';
|
||||
// 선택 가능 옵션에서 '기본' 타입은 제외
|
||||
const selectableMainTypes = (mainTypes ?? []).filter((t: any) => t.id !== defaultMainTypeId);
|
||||
const selectableListTypes = (listTypes ?? []).filter((t: any) => t.id !== defaultListTypeId);
|
||||
// 표시 값: 현재 값이 선택 가능 목록에 없으면 첫 번째 항목을 사용
|
||||
const effectiveMainTypeId = selectableMainTypes.some((t: any) => t.id === edit.mainPageViewTypeId)
|
||||
? edit.mainPageViewTypeId
|
||||
: (selectableMainTypes[0]?.id ?? '');
|
||||
const effectiveListTypeId = selectableListTypes.some((t: any) => t.id === edit.listViewTypeId)
|
||||
? edit.listViewTypeId
|
||||
: (selectableListTypes[0]?.id ?? '');
|
||||
return (
|
||||
<>
|
||||
<td className="px-3 py-2"><input className="h-9 w-full rounded-md border border-neutral-300 px-2 text-sm" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||
<td className="px-3 py-2"><input className="h-9 w-full rounded-md border border-neutral-300 px-2 text-sm" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||
<td className="px-3 py-2"><input className="h-9 w-full min-w-[160px] rounded-md border border-neutral-300 px-2 text-sm" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||
<td className="px-3 py-2"><input className="h-9 w-full min-w-[200px] rounded-md border border-neutral-300 px-2 text-sm" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<select
|
||||
className="h-9 rounded-md border border-neutral-300 px-2 text-sm"
|
||||
@@ -453,12 +463,11 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
|
||||
e.currentTarget.value = id ?? '';
|
||||
return;
|
||||
}
|
||||
const v = { ...edit, mainPageViewTypeId: e.target.value || null };
|
||||
const v = { ...edit, mainPageViewTypeId: e.target.value };
|
||||
setEdit(v); onDirty(b.id, v);
|
||||
}}
|
||||
>
|
||||
<option value="">(없음)</option>
|
||||
{(mainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||
{(selectableMainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||
<option value="__add__">+ 새 타입 추가…</option>
|
||||
</select>
|
||||
</td>
|
||||
@@ -473,12 +482,11 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
|
||||
e.currentTarget.value = id ?? '';
|
||||
return;
|
||||
}
|
||||
const v = { ...edit, listViewTypeId: e.target.value || null };
|
||||
const v = { ...edit, listViewTypeId: e.target.value };
|
||||
setEdit(v); onDirty(b.id, v);
|
||||
}}
|
||||
>
|
||||
<option value="">(없음)</option>
|
||||
{(listTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||
{(selectableListTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||
<option value="__add__">+ 새 타입 추가…</option>
|
||||
</select>
|
||||
</td>
|
||||
@@ -551,8 +559,8 @@ function CategoryHeaderContent({ g, onDirty }: { g: any; onDirty: (payload: any)
|
||||
return (
|
||||
<>
|
||||
<div className="w-10" />
|
||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} />
|
||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
|
||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48 min-w-[160px]" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} />
|
||||
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48 min-w-[200px]" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
|
||||
<div className="flex-1" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import AdminSidebar from "@/app/admin/AdminSidebar";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Admin | ASSM",
|
||||
};
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
// 서버에서 쿠키 기반 접근 제어 (미들웨어 보조)
|
||||
const h = await headers();
|
||||
const cookieHeader = h.get("cookie") || "";
|
||||
const uid = cookieHeader
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.find((pair) => pair.startsWith("uid="))
|
||||
?.split("=")[1];
|
||||
const isAdmin = cookieHeader
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.find((pair) => pair.startsWith("isAdmin="))
|
||||
?.split("=")[1];
|
||||
if (!uid) {
|
||||
redirect("/login");
|
||||
}
|
||||
if (isAdmin !== "1") {
|
||||
redirect("/");
|
||||
}
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-0px)] flex">
|
||||
<AdminSidebar />
|
||||
|
||||
@@ -7,8 +7,10 @@ const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export default function AdminPartnersPage() {
|
||||
const { data, mutate } = useSWR<{ partners: any[] }>("/api/admin/partners", fetcher);
|
||||
const { data: catData, mutate: mutateCategories } = useSWR<{ categories: any[] }>("/api/admin/partner-categories", fetcher);
|
||||
const partners = data?.partners ?? [];
|
||||
const [form, setForm] = useState({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
|
||||
const categories = catData?.categories ?? [];
|
||||
const [form, setForm] = useState({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" });
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const editFileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -62,8 +64,8 @@ export default function AdminPartnersPage() {
|
||||
|
||||
async function create() {
|
||||
// 필수값 검증: 이름/카테고리/위도/경도
|
||||
if (!form.name || !form.category || !String(form.latitude).trim() || !String(form.longitude).trim()) {
|
||||
alert("이름, 카테고리, 위도, 경도를 모두 입력해 주세요.");
|
||||
if (!form.name || !form.categoryId || !String(form.latitude).trim() || !String(form.longitude).trim()) {
|
||||
alert("이름, 카테고리, 위도, 경도를 모두 선택/입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
const lat = Number(form.latitude);
|
||||
@@ -72,12 +74,21 @@ export default function AdminPartnersPage() {
|
||||
alert("위도/경도는 숫자여야 합니다.");
|
||||
return;
|
||||
}
|
||||
const payload = { ...form, latitude: lat, longitude: lon } as any;
|
||||
const payload = { ...form, latitude: lat, longitude: lon, categoryId: form.categoryId } as any;
|
||||
const r = await fetch("/api/admin/partners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
||||
if (r.ok) {
|
||||
setForm({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
|
||||
setForm({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" });
|
||||
mutate();
|
||||
setShowCreateModal(false);
|
||||
} else {
|
||||
let msg = "저장에 실패했습니다.";
|
||||
try {
|
||||
const j = await r.json();
|
||||
msg = j?.message || j?.error || msg;
|
||||
if (r.status === 409 && j?.error === "duplicate_name") msg = "이미 존재하는 업체명입니다.";
|
||||
if (r.status === 400) msg = msg || "입력값을 확인해 주세요.";
|
||||
} catch {}
|
||||
alert(msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +118,12 @@ export default function AdminPartnersPage() {
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 3" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>카테고리</label>
|
||||
<input style={inputStyle} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} />
|
||||
<select style={inputStyle} value={form.categoryId} onChange={(e) => setForm({ ...form, categoryId: e.target.value })}>
|
||||
<option value="">(없음)</option>
|
||||
{categories.map((c: any) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 3" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
||||
@@ -178,7 +194,12 @@ export default function AdminPartnersPage() {
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 3" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>카테고리</label>
|
||||
<input style={inputStyle} value={editDraft?.category ?? ""} onChange={(e) => setEditDraft({ ...editDraft, category: e.target.value })} />
|
||||
<select style={inputStyle} value={editDraft?.categoryId ?? ""} onChange={(e) => setEditDraft({ ...editDraft, categoryId: e.target.value })}>
|
||||
<option value="">(없음)</option>
|
||||
{categories.map((c: any) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 3" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
||||
@@ -216,7 +237,23 @@ export default function AdminPartnersPage() {
|
||||
)}
|
||||
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||
<button
|
||||
onClick={async () => { if (!editingId) return; await fetch(`/api/admin/partners/${editingId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...editDraft }) }); setEditingId(null); setEditDraft(null); setShowEditModal(false); mutate(); }}
|
||||
onClick={async () => {
|
||||
if (!editingId) return;
|
||||
const resp = await fetch(`/api/admin/partners/${editingId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...editDraft, categoryId: (editDraft?.categoryId || null) }) });
|
||||
if (!resp.ok) {
|
||||
let msg = "저장에 실패했습니다.";
|
||||
try {
|
||||
const j = await resp.json();
|
||||
msg = j?.message || j?.error || msg;
|
||||
} catch {}
|
||||
alert(msg);
|
||||
return;
|
||||
}
|
||||
setEditingId(null);
|
||||
setEditDraft(null);
|
||||
setShowEditModal(false);
|
||||
mutate();
|
||||
}}
|
||||
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
||||
>
|
||||
저장
|
||||
@@ -250,14 +287,14 @@ export default function AdminPartnersPage() {
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<div><strong>{p.name}</strong> <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>{p.category}</span></div>
|
||||
<div><strong>{p.name}</strong> {p.categoryRef ? <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>[{p.categoryRef.name}]</span> : null}</div>
|
||||
<div style={{ fontSize: 12, opacity: .7 }}>{p.address || "(주소 없음)"}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, opacity: .7 }}>위도 {p.latitude}</div>
|
||||
<div style={{ fontSize: 12, opacity: .7 }}>경도 {p.longitude}</div>
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={() => { setEditingId(p.id); setEditDraft({ name: p.name, category: p.category, latitude: String(p.latitude), longitude: String(p.longitude), address: p.address ?? "", imageUrl: p.imageUrl ?? "", sortOrder: p.sortOrder ?? 0 }); setShowEditModal(true); }}
|
||||
onClick={() => { setEditingId(p.id); setEditDraft({ name: p.name, categoryId: p.categoryId ?? "", latitude: String(p.latitude), longitude: String(p.longitude), address: p.address ?? "", imageUrl: p.imageUrl ?? "", sortOrder: p.sortOrder ?? 0 }); setShowEditModal(true); }}
|
||||
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
||||
>
|
||||
수정
|
||||
@@ -288,8 +325,49 @@ export default function AdminPartnersPage() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div>
|
||||
<hr style={{ margin: "24px 0" }} />
|
||||
<h2 className="text-lg font-bold mb-2">카테고리 관리</h2>
|
||||
<CategoryManager categories={categories} onChanged={mutateCategories} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function CategoryManager({ categories, onChanged }: { categories: any[]; onChanged: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
return (
|
||||
<div className="border border-neutral-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<input
|
||||
placeholder="카테고리 이름"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="h-9 px-3 rounded-md border border-neutral-300 flex-1"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => { if (!name.trim()) return; const r = await fetch("/api/admin/partner-categories", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: name.trim() }) }); if (r.ok) { setName(""); onChanged(); } else { try { const j = await r.json(); alert(j?.message || j?.error || "생성 실패"); } catch { alert("생성 실패"); } } }}
|
||||
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{categories.map((c: any) => (
|
||||
<li key={c.id} className="flex items-center gap-2 px-2 py-1 rounded-full border border-neutral-300 text-sm">
|
||||
<span>{c.name}</span>
|
||||
<button
|
||||
title="삭제"
|
||||
onClick={async () => { const r = await fetch(`/api/admin/partner-categories/${c.id}`, { method: "DELETE" }); if (r.ok) onChanged(); else { try { const j = await r.json(); alert(j?.message || j?.error || "삭제 실패"); } catch { alert("삭제 실패"); } } }}
|
||||
className="px-2 h-6 rounded-md border border-red-200 text-red-600 hover:bg-red-100 hover:border-red-300 hover:text-red-700"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function AdminUsersPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[#f6f4f4] border-b border-[#e6e6e6]">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">ID</th>
|
||||
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">닉네임</th>
|
||||
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">이름</th>
|
||||
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">전화</th>
|
||||
@@ -90,6 +91,7 @@ function Row({ u, onChanged }: { u: any; onChanged: () => void }) {
|
||||
const allRoles = ["admin", "editor", "user"] as const;
|
||||
return (
|
||||
<tr className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-2 text-left tabular-nums">{u.userId}</td>
|
||||
<td className="px-4 py-2 text-left">{u.nickname}</td>
|
||||
<td className="px-4 py-2 text-left">{u.name}</td>
|
||||
<td className="px-4 py-2 text-left">{u.phone}</td>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
28
src/app/api/admin/partner-categories/[id]/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await context.params;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const data: any = {};
|
||||
for (const k of ["name", "sortOrder"]) if (k in body) data[k] = body[k];
|
||||
try {
|
||||
const category = await prisma.partnerCategory.update({ where: { id }, data });
|
||||
return NextResponse.json({ category });
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'P2002') return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 카테고리입니다.' }, { status: 409 });
|
||||
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await context.params;
|
||||
try {
|
||||
await prisma.partnerCategory.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: 'category_in_use', message: '해당 카테고리를 사용하는 제휴업체가 있습니다.' }, { status: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
25
src/app/api/admin/partner-categories/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function GET() {
|
||||
const categories = await prisma.partnerCategory.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }] });
|
||||
return NextResponse.json({ categories });
|
||||
}
|
||||
|
||||
const createSchema = z.object({ name: z.string().min(1), sortOrder: z.coerce.number().int().optional() });
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
try {
|
||||
const category = await prisma.partnerCategory.create({ data: { name: parsed.data.name, sortOrder: parsed.data.sortOrder ?? 0 } });
|
||||
return NextResponse.json({ category }, { status: 201 });
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'P2002') return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 카테고리입니다.' }, { status: 409 });
|
||||
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
|
||||
for (const k of ["region", "name", "address", "imageUrl", "active", "sortOrder"]) {
|
||||
if (k in body) data[k] = body[k];
|
||||
}
|
||||
const item = await prisma.partnerShop.update({ where: { id }, data });
|
||||
const item = await prisma.partnerRequest.update({ where: { id }, data });
|
||||
return NextResponse.json({ item });
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await context.params;
|
||||
await prisma.partnerShop.delete({ where: { id } });
|
||||
await prisma.partnerRequest.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,19 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
|
||||
const { id } = await context.params;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const data: any = {};
|
||||
for (const k of ["name", "category", "latitude", "longitude", "address", "imageUrl", "sortOrder"]) {
|
||||
for (const k of ["name", "category", "latitude", "longitude", "address", "imageUrl", "sortOrder", "categoryId"]) {
|
||||
if (k in body) data[k] = body[k];
|
||||
}
|
||||
if (typeof data.latitude !== "undefined") data.latitude = Number(data.latitude);
|
||||
if (typeof data.longitude !== "undefined") data.longitude = Number(data.longitude);
|
||||
if (typeof data.sortOrder !== "undefined") data.sortOrder = Number(data.sortOrder);
|
||||
// categoryId가 들어왔고 category 문자열이 비어있으면 카테고리명으로 채움
|
||||
if (typeof data.categoryId !== "undefined" && (typeof data.category === "undefined" || data.category === null)) {
|
||||
if (data.categoryId) {
|
||||
const cat = await prisma.partnerCategory.findUnique({ where: { id: String(data.categoryId) } });
|
||||
if (cat) data.category = cat.name;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const partner = await prisma.partner.update({ where: { id }, data });
|
||||
return NextResponse.json({ partner });
|
||||
|
||||
@@ -4,19 +4,19 @@ import { z } from "zod";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// 정렬용 컬럼(sortOrder)이 있는 경우 우선 사용
|
||||
const partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }] });
|
||||
// 카테고리 조인 포함
|
||||
const partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], include: { categoryRef: true } });
|
||||
return NextResponse.json({ partners });
|
||||
} catch (_) {
|
||||
// 컬럼이 아직 DB에 없으면 createdAt 기준으로 폴백
|
||||
const partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" } });
|
||||
const partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" }, include: { categoryRef: true } });
|
||||
return NextResponse.json({ partners });
|
||||
}
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
category: z.string().min(1),
|
||||
category: z.string().min(1).optional(),
|
||||
latitude: z.coerce.number(),
|
||||
longitude: z.coerce.number(),
|
||||
address: z.string().min(1).optional(),
|
||||
@@ -27,14 +27,28 @@ const createSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
sortOrder: z.coerce.number().int().optional(),
|
||||
categoryId: z.string().min(1),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const partner = await prisma.partner.create({ data: parsed.data as any });
|
||||
return NextResponse.json({ partner }, { status: 201 });
|
||||
try {
|
||||
const { categoryId } = parsed.data as any;
|
||||
const cat = await prisma.partnerCategory.findUnique({ where: { id: categoryId } });
|
||||
if (!cat) return NextResponse.json({ error: 'invalid_category', message: '유효하지 않은 카테고리입니다.' }, { status: 400 });
|
||||
const data: any = { ...parsed.data };
|
||||
if (!data.category) data.category = cat.name;
|
||||
const partner = await prisma.partner.create({ data });
|
||||
return NextResponse.json({ partner }, { status: 201 });
|
||||
} catch (e: any) {
|
||||
// Unique name 에러 처리
|
||||
if (e?.code === 'P2002') {
|
||||
return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 업체명입니다.' }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
77
src/app/api/attendance/me-stats/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getUserIdFromRequest } from "@/lib/auth";
|
||||
|
||||
function toYmdUTC(d: Date): string {
|
||||
const yy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${yy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const userId = getUserIdFromRequest(req);
|
||||
if (!userId) return NextResponse.json({ total: 0, currentStreak: 0, maxStreak: 0 });
|
||||
|
||||
// 총 출석일
|
||||
const total = await prisma.attendance.count({ where: { userId } });
|
||||
|
||||
// 모든 출석일(UTC 자정 기준) 가져와서 streak 계산
|
||||
const rows = await prisma.attendance.findMany({
|
||||
where: { userId },
|
||||
select: { date: true },
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
const days = Array.from(new Set(rows.map((r) => toYmdUTC(new Date(r.date))))); // unique, asc
|
||||
|
||||
// 현재 연속 출석
|
||||
let currentStreak = 0;
|
||||
if (days.length > 0) {
|
||||
const set = new Set(days);
|
||||
const now = new Date();
|
||||
// 로컬 날짜(사용자 체감 날짜)를 UTC 자정으로 정규화하여 비교
|
||||
let cursor = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0));
|
||||
let tried = 0;
|
||||
while (tried < 2 && currentStreak === 0) {
|
||||
const startYmd = toYmdUTC(cursor);
|
||||
if (!set.has(startYmd)) {
|
||||
cursor.setUTCDate(cursor.getUTCDate() - 1);
|
||||
tried += 1;
|
||||
continue;
|
||||
}
|
||||
while (true) {
|
||||
const ymd = toYmdUTC(cursor);
|
||||
if (set.has(ymd)) {
|
||||
currentStreak += 1;
|
||||
cursor.setUTCDate(cursor.getUTCDate() - 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 최대 연속 출석
|
||||
let maxStreak = 0;
|
||||
if (days.length > 0) {
|
||||
// scan through sorted list
|
||||
let localMax = 1;
|
||||
for (let i = 1; i < days.length; i++) {
|
||||
const prev = new Date(days[i - 1] + "T00:00:00.000Z");
|
||||
const cur = new Date(days[i] + "T00:00:00.000Z");
|
||||
const diff = (cur.getTime() - prev.getTime()) / (24 * 60 * 60 * 1000);
|
||||
if (diff === 1) {
|
||||
localMax += 1;
|
||||
} else if (diff > 1) {
|
||||
if (localMax > maxStreak) maxStreak = localMax;
|
||||
localMax = 1;
|
||||
}
|
||||
}
|
||||
if (localMax > maxStreak) maxStreak = localMax;
|
||||
}
|
||||
|
||||
return NextResponse.json({ total, currentStreak, maxStreak });
|
||||
}
|
||||
|
||||
|
||||
116
src/app/api/attendance/rankings/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
function toYmdUTC(d: Date): string {
|
||||
const yy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${yy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const only = url.searchParams.get("only");
|
||||
|
||||
// 최대 연속 출석 상위 (Top 20, 전체 이력 기준)
|
||||
async function computeStreakTop() {
|
||||
// 전체 이력 기준으로 사용자별 최대 연속 출석을 계산
|
||||
const rows = await prisma.attendance.findMany({
|
||||
select: { userId: true, date: true },
|
||||
orderBy: [{ userId: "asc" }, { date: "asc" }],
|
||||
});
|
||||
const maxByUser = new Map<string, number>();
|
||||
let currentUserId: string | null = null;
|
||||
let lastDayMs: number | null = null;
|
||||
let current = 0;
|
||||
let maxStreak = 0;
|
||||
const commit = () => {
|
||||
if (currentUserId) {
|
||||
maxByUser.set(currentUserId, Math.max(maxByUser.get(currentUserId) ?? 0, maxStreak));
|
||||
}
|
||||
};
|
||||
for (const r of rows) {
|
||||
if (r.userId !== currentUserId) {
|
||||
// flush previous
|
||||
if (currentUserId !== null) commit();
|
||||
// reset for new user
|
||||
currentUserId = r.userId;
|
||||
lastDayMs = null;
|
||||
current = 0;
|
||||
maxStreak = 0;
|
||||
}
|
||||
// UTC 일 단위로 비교
|
||||
const ymd = toYmdUTC(new Date(r.date));
|
||||
const ms = Date.parse(`${ymd}T00:00:00.000Z`);
|
||||
if (lastDayMs === null) {
|
||||
current = 1;
|
||||
} else {
|
||||
const diffDays = Math.round((ms - lastDayMs) / (24 * 60 * 60 * 1000));
|
||||
if (diffDays === 1) {
|
||||
current += 1;
|
||||
} else if (diffDays > 0) {
|
||||
current = 1;
|
||||
} else {
|
||||
// 동일/역순은 이례적이지만 안전하게 스킵
|
||||
current = Math.max(current, 1);
|
||||
}
|
||||
}
|
||||
if (current > maxStreak) maxStreak = current;
|
||||
lastDayMs = ms;
|
||||
}
|
||||
// flush last
|
||||
if (currentUserId !== null) commit();
|
||||
|
||||
const topStreak = Array.from(maxByUser.entries())
|
||||
.map(([userId, streak]) => ({ userId, streak }))
|
||||
.sort((a, b) => b.streak - a.streak)
|
||||
.slice(0, 20);
|
||||
const streakUserIds = topStreak.map((s) => s.userId);
|
||||
const streakUsers = await prisma.user.findMany({
|
||||
where: { userId: { in: streakUserIds } },
|
||||
select: { userId: true, nickname: true, profileImage: true, grade: true },
|
||||
});
|
||||
const streakMeta = new Map(streakUsers.map((u) => [u.userId, u]));
|
||||
const streak = topStreak.map((s) => ({
|
||||
userId: s.userId,
|
||||
nickname: streakMeta.get(s.userId)?.nickname ?? "회원",
|
||||
streak: s.streak,
|
||||
profileImage: streakMeta.get(s.userId)?.profileImage ?? null,
|
||||
grade: streakMeta.get(s.userId)?.grade ?? 0,
|
||||
}));
|
||||
return streak;
|
||||
}
|
||||
|
||||
if (only === "streak") {
|
||||
const streak = await computeStreakTop();
|
||||
return NextResponse.json({ streak });
|
||||
}
|
||||
|
||||
// 전체 출석 누적 상위 (Top 10)
|
||||
const overallGroups = await prisma.attendance.groupBy({
|
||||
by: ["userId"],
|
||||
_count: { date: true },
|
||||
orderBy: { _count: { date: "desc" } },
|
||||
take: 20,
|
||||
});
|
||||
const overallUserIds = overallGroups.map((g) => g.userId);
|
||||
const overallUsers = await prisma.user.findMany({
|
||||
where: { userId: { in: overallUserIds } },
|
||||
select: { userId: true, nickname: true, profileImage: true, grade: true },
|
||||
});
|
||||
const userMeta = new Map(overallUsers.map((u) => [u.userId, u]));
|
||||
const overall = overallGroups.map((g) => ({
|
||||
userId: g.userId,
|
||||
nickname: userMeta.get(g.userId)?.nickname ?? "회원",
|
||||
count: g._count.date ?? 0,
|
||||
profileImage: userMeta.get(g.userId)?.profileImage ?? null,
|
||||
grade: userMeta.get(g.userId)?.grade ?? 0,
|
||||
}));
|
||||
|
||||
const streak = await computeStreakTop();
|
||||
|
||||
return NextResponse.json({ overall, streak });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getUserIdFromRequest } from "@/lib/auth";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const userId = getUserIdFromRequest(req);
|
||||
if (!userId) return NextResponse.json({ today: null, count: 0 });
|
||||
if (!userId) return NextResponse.json({ today: null, count: 0, days: [] });
|
||||
const url = new URL(req.url);
|
||||
const year = url.searchParams.get("year");
|
||||
const month = url.searchParams.get("month"); // 1-12
|
||||
const start = new Date(); start.setHours(0,0,0,0);
|
||||
const end = new Date(); end.setHours(23,59,59,999);
|
||||
const today = await prisma.pointTransaction.findFirst({
|
||||
where: { userId, reason: "attendance", createdAt: { gte: start, lte: end } },
|
||||
const today = await prisma.attendance.findFirst({
|
||||
where: { userId, date: { gte: start, lte: end } },
|
||||
});
|
||||
const count = await prisma.pointTransaction.count({ where: { userId, reason: "attendance" } });
|
||||
const count = await prisma.attendance.count({ where: { userId } });
|
||||
// 월별 출석 일자 목록
|
||||
if (year && month) {
|
||||
const y = parseInt(year, 10);
|
||||
const m = parseInt(month, 10);
|
||||
if (!isNaN(y) && !isNaN(m) && m >= 1 && m <= 12) {
|
||||
const firstDay = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0, 0));
|
||||
const lastDay = new Date(Date.UTC(y, m, 0, 23, 59, 59, 999));
|
||||
const records = await prisma.attendance.findMany({
|
||||
where: { userId, date: { gte: firstDay, lte: lastDay } },
|
||||
select: { date: true },
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
const days = records.map(r => {
|
||||
const d = new Date(r.date);
|
||||
const yy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${yy}-${mm}-${dd}`;
|
||||
});
|
||||
return NextResponse.json({ today: !!today, count, days });
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ today: !!today, count });
|
||||
}
|
||||
|
||||
@@ -19,9 +45,12 @@ export async function POST(req: Request) {
|
||||
if (!userId) return NextResponse.json({ error: "login required" }, { status: 401 });
|
||||
const start = new Date(); start.setHours(0,0,0,0);
|
||||
const end = new Date(); end.setHours(23,59,59,999);
|
||||
const exists = await prisma.pointTransaction.findFirst({ where: { userId, reason: "attendance", createdAt: { gte: start, lte: end } } });
|
||||
const exists = await prisma.attendance.findFirst({ where: { userId, date: { gte: start, lte: end } } });
|
||||
if (exists) return NextResponse.json({ ok: true, duplicated: true });
|
||||
await prisma.pointTransaction.create({ data: { userId, amount: 10, reason: "attendance" } });
|
||||
// normalize to UTC midnight
|
||||
const now = new Date();
|
||||
const normalized = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0));
|
||||
await prisma.attendance.create({ data: { userId, date: normalized } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
23
src/app/api/auth/check-name/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { nameSchema } from "@/lib/validation/auth";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const nameRaw = searchParams.get("name") ?? "";
|
||||
const name = nameRaw.trim();
|
||||
|
||||
const parsed = nameSchema.safeParse(name);
|
||||
if (!parsed.success) {
|
||||
const firstMsg = parsed.error.issues[0]?.message || "잘못된 닉네임 형식";
|
||||
return NextResponse.json(
|
||||
{ error: { fieldErrors: { name: [firstMsg] } } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const exists = await prisma.user.findFirst({ where: { name } });
|
||||
return NextResponse.json({ available: !exists });
|
||||
}
|
||||
|
||||
|
||||
23
src/app/api/auth/check-nickname/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { nicknameSchema } from "@/lib/validation/auth";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const nicknameRaw = searchParams.get("nickname") ?? "";
|
||||
const nickname = nicknameRaw.trim();
|
||||
|
||||
const parsed = nicknameSchema.safeParse(nickname);
|
||||
if (!parsed.success) {
|
||||
const firstMsg = parsed.error.issues[0]?.message || "잘못된 아이디 형식";
|
||||
return NextResponse.json(
|
||||
{ error: { fieldErrors: { nickname: [firstMsg] } } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const exists = await prisma.user.findUnique({ where: { nickname } });
|
||||
return NextResponse.json({ available: !exists });
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,18 @@ export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
const parsed = loginSchema.safeParse(body);
|
||||
if (!parsed.success)
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const { nickname, password } = parsed.data;
|
||||
const user = await prisma.user.findUnique({ where: { nickname } });
|
||||
return NextResponse.json(
|
||||
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
|
||||
{ status: 401 }
|
||||
);
|
||||
const { id, password } = parsed.data;
|
||||
// DB에서는 로그인 아이디를 nickname 컬럼으로 보관
|
||||
const user = await prisma.user.findUnique({ where: { nickname: id } });
|
||||
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
|
||||
return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ user: { userId: user.userId, nickname: user.nickname } });
|
||||
}
|
||||
|
||||
@@ -5,13 +5,33 @@ import { getUserIdFromRequest } from "@/lib/auth";
|
||||
export async function GET(req: Request) {
|
||||
const userId = getUserIdFromRequest(req);
|
||||
if (!userId) return NextResponse.json({ permissions: [] });
|
||||
const roles = await prisma.userRole.findMany({ where: { userId }, select: { roleId: true } });
|
||||
if (roles.length === 0) return NextResponse.json({ permissions: [] });
|
||||
const roleIds = roles.map((r) => r.roleId);
|
||||
const permissions = await prisma.rolePermission.findMany({
|
||||
where: { roleId: { in: roleIds }, allowed: true },
|
||||
select: { resource: true, action: true },
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { userId },
|
||||
select: {
|
||||
authLevel: true,
|
||||
userRoles: { select: { roleId: true } },
|
||||
},
|
||||
});
|
||||
if (!user) return NextResponse.json({ permissions: [] });
|
||||
|
||||
const roleIds = user.userRoles.map((r) => r.roleId);
|
||||
const rolePermissions =
|
||||
roleIds.length > 0
|
||||
? await prisma.rolePermission.findMany({
|
||||
where: { roleId: { in: roleIds }, allowed: true },
|
||||
select: { resource: true, action: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const hasAdminPerm = rolePermissions.some(
|
||||
(perm) => perm.resource === "ADMIN" && perm.action === "ADMINISTER"
|
||||
);
|
||||
const permissions =
|
||||
user.authLevel === "ADMIN" && !hasAdminPerm
|
||||
? [{ resource: "ADMIN" as const, action: "ADMINISTER" as const }, ...rolePermissions]
|
||||
: rolePermissions;
|
||||
|
||||
return NextResponse.json({ permissions });
|
||||
}
|
||||
|
||||
|
||||
@@ -8,17 +8,41 @@ export async function POST(req: Request) {
|
||||
const parsed = registerSchema.safeParse(body);
|
||||
if (!parsed.success)
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const { nickname, name, phone, birth, password } = parsed.data;
|
||||
const exists = await prisma.user.findFirst({ where: { OR: [{ nickname }, { phone }] } });
|
||||
if (exists) return NextResponse.json({ error: "이미 존재하는 사용자" }, { status: 409 });
|
||||
const { nickname, name, password, profileImage } = parsed.data as {
|
||||
nickname: string;
|
||||
name: string;
|
||||
password: string;
|
||||
profileImage?: string;
|
||||
};
|
||||
// 아이디(닉네임 필드와 구분)를 우선 검사
|
||||
const nicknameExists = await prisma.user.findFirst({ where: { nickname } });
|
||||
if (nicknameExists) {
|
||||
return NextResponse.json(
|
||||
{ error: { fieldErrors: { nickname: ["이미 사용 중인 아이디입니다"] } } },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
// 표시용 닉네임(name)도 유일해야 함
|
||||
const nameExists = await prisma.user.findFirst({ where: { name } });
|
||||
if (nameExists) {
|
||||
return NextResponse.json(
|
||||
{ error: { fieldErrors: { name: ["이미 사용 중인 닉네임입니다"] } } },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
userId: nickname,
|
||||
nickname,
|
||||
name,
|
||||
phone,
|
||||
birth: new Date(birth),
|
||||
passwordHash: hashPassword(password),
|
||||
agreementTermsAt: new Date(),
|
||||
// 일부 환경에서 birth 컬럼이 NOT NULL 제약으로 남아있는 경우가 있어 안전 기본값을 기록
|
||||
birth: new Date(0),
|
||||
// 일부 환경에서 phone 컬럼이 NOT NULL+UNIQUE 제약으로 남아있는 경우가 있어
|
||||
// 임시 유니크 플레이스홀더를 기록합니다. (후속 마이그레이션으로 NULL 허용 권장)
|
||||
phone: `ph_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
|
||||
profileImage: profileImage || null,
|
||||
},
|
||||
select: { userId: true, nickname: true },
|
||||
});
|
||||
|
||||
@@ -29,16 +29,59 @@ export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
const parsed = loginSchema.safeParse(body);
|
||||
if (!parsed.success)
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const { nickname, password } = parsed.data;
|
||||
const user = await prisma.user.findUnique({ where: { nickname } });
|
||||
return NextResponse.json(
|
||||
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
|
||||
{ status: 401 }
|
||||
);
|
||||
const { id, password } = parsed.data;
|
||||
// DB에서는 로그인 아이디를 nickname 컬럼으로 보관
|
||||
const user = await prisma.user.findUnique({ where: { nickname: id } });
|
||||
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
|
||||
return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
// 사용자의 관리자 권한 여부 확인
|
||||
let isAdmin = false;
|
||||
const userRoles = await prisma.userRole.findMany({
|
||||
where: { userId: user.userId },
|
||||
select: { roleId: true },
|
||||
});
|
||||
if (userRoles.length > 0) {
|
||||
const roleIds = userRoles.map((r) => r.roleId);
|
||||
const hasAdmin = await prisma.rolePermission.findFirst({
|
||||
where: {
|
||||
roleId: { in: roleIds },
|
||||
resource: "ADMIN",
|
||||
action: "ADMINISTER",
|
||||
allowed: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
isAdmin = !!hasAdmin;
|
||||
}
|
||||
// 추가 안전장치: 사용자 레코드의 authLevel이 ADMIN이면 관리자 취급
|
||||
if (!isAdmin && user.authLevel === "ADMIN") {
|
||||
isAdmin = true;
|
||||
}
|
||||
|
||||
const res = NextResponse.json({ ok: true, user: { userId: user.userId, nickname: user.nickname } });
|
||||
// HTTPS 요청에서만 Secure 속성 부여 (HTTP 환경에서는 생략하여 로컬 start에서도 동작)
|
||||
let secureAttr = "";
|
||||
try {
|
||||
const isHttps = new URL(req.url).protocol === "https:";
|
||||
secureAttr = isHttps ? "; Secure" : "";
|
||||
} catch {
|
||||
secureAttr = "";
|
||||
}
|
||||
res.headers.append(
|
||||
"Set-Cookie",
|
||||
`uid=${encodeURIComponent(user.userId)}; Path=/; HttpOnly; SameSite=Lax`
|
||||
`uid=${encodeURIComponent(user.userId)}; Path=/; HttpOnly; SameSite=Lax${secureAttr}`
|
||||
);
|
||||
res.headers.append(
|
||||
"Set-Cookie",
|
||||
`isAdmin=${isAdmin ? "1" : "0"}; Path=/; HttpOnly; SameSite=Lax${secureAttr}`
|
||||
);
|
||||
return res;
|
||||
}
|
||||
@@ -46,6 +89,7 @@ export async function POST(req: Request) {
|
||||
export async function DELETE() {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.headers.append("Set-Cookie", `uid=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`);
|
||||
res.headers.append("Set-Cookie", `isAdmin=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`);
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import prisma from "@/lib/prisma";
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const category = searchParams.get("category"); // slug or id
|
||||
const where: any = {};
|
||||
const where: any = { status: "active" };
|
||||
if (category) {
|
||||
if (category.length === 25 || category.length === 24) {
|
||||
where.categoryId = category;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
43
src/app/api/me/password/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getUserIdOrAdmin } from "@/lib/auth";
|
||||
import { verifyPassword, hashPassword } from "@/lib/password";
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const userId = await getUserIdOrAdmin(req);
|
||||
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
try {
|
||||
const body = await req.json();
|
||||
const currentPassword: string | undefined = body?.currentPassword;
|
||||
const newPassword: string | undefined = body?.newPassword;
|
||||
if (!currentPassword || !newPassword) {
|
||||
return NextResponse.json({ error: "currentPassword and newPassword required" }, { status: 400 });
|
||||
}
|
||||
if (newPassword.length < 8 || newPassword.length > 100) {
|
||||
return NextResponse.json({ error: "password length invalid" }, { status: 400 });
|
||||
}
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { userId },
|
||||
select: { passwordHash: true },
|
||||
});
|
||||
if (!user || !user.passwordHash) {
|
||||
return NextResponse.json({ error: "invalid user" }, { status: 400 });
|
||||
}
|
||||
if (!verifyPassword(currentPassword, user.passwordHash)) {
|
||||
return NextResponse.json({ error: "현재 비밀번호가 올바르지 않습니다" }, { status: 400 });
|
||||
}
|
||||
if (verifyPassword(newPassword, user.passwordHash)) {
|
||||
// 새 비밀번호가 기존과 동일
|
||||
return NextResponse.json({ error: "새 비밀번호가 기존과 동일합니다" }, { status: 400 });
|
||||
}
|
||||
await prisma.user.update({
|
||||
where: { userId },
|
||||
data: { passwordHash: hashPassword(newPassword) },
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Bad Request" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
31
src/app/api/me/profile-image/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getUserIdOrAdmin } from "@/lib/auth";
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const userId = await getUserIdOrAdmin(req);
|
||||
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
try {
|
||||
const body = await req.json();
|
||||
const url: string | null = body?.url ?? null;
|
||||
if (url !== null && typeof url !== "string") {
|
||||
return NextResponse.json({ error: "invalid url" }, { status: 400 });
|
||||
}
|
||||
// 간단 검증: 내부 업로드 경로 또는 http(s) 허용
|
||||
if (url) {
|
||||
const ok = url.startsWith("/uploads/") || url.startsWith("http://") || url.startsWith("https://");
|
||||
if (!ok) return NextResponse.json({ error: "invalid url" }, { status: 400 });
|
||||
if (url.length > 1000) return NextResponse.json({ error: "url too long" }, { status: 400 });
|
||||
}
|
||||
const user = await prisma.user.update({
|
||||
where: { userId },
|
||||
data: { profileImage: url || null },
|
||||
select: { userId: true, profileImage: true },
|
||||
});
|
||||
return NextResponse.json({ ok: true, user });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Bad Request" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
22
src/app/api/me/route.ts
Normal 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 });
|
||||
}
|
||||
|
||||
|
||||
77
src/app/api/messages/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { z } from "zod";
|
||||
import { getUserIdFromRequest } from "@/lib/auth";
|
||||
|
||||
const sendSchema = z.object({
|
||||
receiverId: z.string().min(1, "수신자 정보가 없습니다"),
|
||||
body: z.string().min(1, "메시지를 입력하세요").max(2000, "메시지는 2000자 이하여야 합니다"),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const senderId = getUserIdFromRequest(req);
|
||||
if (!senderId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
const contentType = req.headers.get("content-type") || "";
|
||||
let data: any = {};
|
||||
if (contentType.includes("application/json")) {
|
||||
data = await req.json().catch(() => ({}));
|
||||
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
||||
const form = await req.formData();
|
||||
data = Object.fromEntries(form.entries());
|
||||
} else {
|
||||
// 기본적으로 form 으로 간주
|
||||
const form = await req.formData().catch(() => null);
|
||||
if (form) data = Object.fromEntries(form.entries());
|
||||
}
|
||||
const parsed = sendSchema.safeParse(data);
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const { receiverId, body } = parsed.data;
|
||||
if (receiverId === senderId) {
|
||||
return NextResponse.json({ error: "자기 자신에게는 보낼 수 없습니다" }, { status: 400 });
|
||||
}
|
||||
const receiver = await prisma.user.findUnique({ where: { userId: receiverId }, select: { userId: true } });
|
||||
if (!receiver) return NextResponse.json({ error: "수신자를 찾을 수 없습니다" }, { status: 404 });
|
||||
const message = await prisma.message.create({
|
||||
data: { senderId, receiverId, body },
|
||||
select: { id: true, senderId: true, receiverId: true, body: true, createdAt: true },
|
||||
});
|
||||
return NextResponse.json({ message }, { status: 201 });
|
||||
}
|
||||
|
||||
const listQuery = z.object({
|
||||
box: z.enum(["received", "sent"]).default("received").optional(),
|
||||
page: z.coerce.number().min(1).default(1).optional(),
|
||||
pageSize: z.coerce.number().min(1).max(100).default(20).optional(),
|
||||
});
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const userId = getUserIdFromRequest(req);
|
||||
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
const { searchParams } = new URL(req.url);
|
||||
const parsed = listQuery.safeParse(Object.fromEntries(searchParams));
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const { box = "received", page = 1, pageSize = 20 } = parsed.data;
|
||||
const where =
|
||||
box === "received"
|
||||
? { receiverId: userId }
|
||||
: { senderId: userId };
|
||||
const [total, items] = await Promise.all([
|
||||
prisma.message.count({ where }),
|
||||
prisma.message.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
body: true,
|
||||
createdAt: true,
|
||||
sender: { select: { userId: true, nickname: true } },
|
||||
receiver: { select: { userId: true, nickname: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return NextResponse.json({ total, page, pageSize, items });
|
||||
}
|
||||
|
||||
|
||||
9
src/app/api/partner-categories/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const categories = await prisma.partnerCategory.findMany({ orderBy: [{ sortOrder: "asc" }, { name: "asc" }] });
|
||||
return NextResponse.json({ categories });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@ import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const items = await prisma.partnerShop.findMany({
|
||||
where: { active: true },
|
||||
// 통합: 승인된 파트너 요청을 메인 노출용으로 사용
|
||||
const rows = await prisma.partnerRequest.findMany({
|
||||
where: { status: "approved", active: true },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
select: { id: true, region: true, name: true, address: true, imageUrl: true, sortOrder: true, active: true, category: true },
|
||||
});
|
||||
const items = rows.map((r) => ({ id: r.id, region: r.region || "", name: r.name, address: r.address || "", imageUrl: r.imageUrl || "", sortOrder: r.sortOrder, active: r.active, category: r.category }));
|
||||
return NextResponse.json({ items });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
function haversine(lat1: number, lon1: number, lat2: number, lon2: number) {
|
||||
const toRad = (v: number) => (v * Math.PI) / 180;
|
||||
const R = 6371; // km
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const lat = Number(searchParams.get("lat"));
|
||||
const lon = Number(searchParams.get("lon"));
|
||||
const category = searchParams.get("category") || undefined;
|
||||
const radius = Number(searchParams.get("radius")) || 10; // km
|
||||
const where = category ? { category } : {};
|
||||
const categoryId = searchParams.get("categoryId") || undefined;
|
||||
let where: any = {};
|
||||
if (categoryId) {
|
||||
// 카테고리 ID 매칭 + 과거 데이터 호환(문자열 category명 매칭)
|
||||
let catName: string | undefined;
|
||||
try {
|
||||
const cat = await prisma.partnerCategory.findUnique({ where: { id: categoryId }, select: { name: true } });
|
||||
catName = cat?.name;
|
||||
} catch {}
|
||||
where = catName ? { OR: [{ categoryId }, { category: catName }] } : { categoryId };
|
||||
} else if (category) {
|
||||
where = { category };
|
||||
}
|
||||
const partners = await prisma.partner.findMany({ where, orderBy: { createdAt: "desc" } });
|
||||
const withDistance = isFinite(lat) && isFinite(lon)
|
||||
? partners.map((p) => ({ ...p, distance: haversine(lat, lon, p.latitude, p.longitude) })).filter((p) => p.distance <= radius)
|
||||
: partners.map((p) => ({ ...p, distance: null }));
|
||||
withDistance.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0));
|
||||
return NextResponse.json({ partners: withDistance });
|
||||
return NextResponse.json({ partners });
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({ userId: z.string().optional(), clientHash: z.string().optional() }).refine(
|
||||
(d) => !!d.userId || !!d.clientHash,
|
||||
{ message: "Provide userId or clientHash" }
|
||||
);
|
||||
import { getUserIdFromRequest } from "@/lib/auth";
|
||||
import crypto from "crypto";
|
||||
|
||||
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await context.params;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const parsed = schema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const { userId, clientHash } = parsed.data;
|
||||
// 1) 사용자 식별 시도: 쿠키/헤더에서 userId 우선
|
||||
let userId = getUserIdFromRequest(req);
|
||||
|
||||
// 2) 바디에서 clientHash 수용(클라이언트가 보낼 수 있음)
|
||||
let clientHash: string | null = null;
|
||||
// JSON 우선
|
||||
const jsonBody = await req
|
||||
.json()
|
||||
.catch(() => null);
|
||||
if (jsonBody && typeof jsonBody === "object") {
|
||||
if (!userId && typeof jsonBody.userId === "string" && jsonBody.userId.length > 0) {
|
||||
userId = jsonBody.userId;
|
||||
}
|
||||
if (typeof jsonBody.clientHash === "string" && jsonBody.clientHash.length > 0) {
|
||||
clientHash = jsonBody.clientHash;
|
||||
}
|
||||
} else {
|
||||
// form 제출도 허용 (빈 폼일 수 있음)
|
||||
const form = await req
|
||||
.formData()
|
||||
.catch(() => null);
|
||||
if (form) {
|
||||
const formUserId = form.get("userId");
|
||||
const formClientHash = form.get("clientHash");
|
||||
if (!userId && typeof formUserId === "string" && formUserId.length > 0) {
|
||||
userId = formUserId;
|
||||
}
|
||||
if (typeof formClientHash === "string" && formClientHash.length > 0) {
|
||||
clientHash = formClientHash;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 둘 다 없으면 서버에서 익명 고유 clientHash 생성(IP + UA 기반)
|
||||
if (!userId && !clientHash) {
|
||||
const ua = req.headers.get("user-agent") || "";
|
||||
// x-forwarded-for 첫번째가 원 IP 가정
|
||||
const ip =
|
||||
(req.headers.get("x-forwarded-for") || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())[0] ||
|
||||
req.headers.get("x-real-ip") ||
|
||||
"";
|
||||
const raw = `${ip}::${ua}`;
|
||||
clientHash = crypto.createHash("sha256").update(raw).digest("hex").slice(0, 32);
|
||||
}
|
||||
|
||||
const existing = await prisma.reaction.findFirst({
|
||||
where: { postId: id, type: "RECOMMEND", userId: userId ?? null, clientHash: clientHash ?? null },
|
||||
@@ -27,6 +65,19 @@ export async function POST(req: Request, context: { params: Promise<{ id: string
|
||||
});
|
||||
}
|
||||
|
||||
// 폼 제출의 경우, JSON 대신 원래 페이지로 리다이렉트
|
||||
// jsonBody가 없고(formData 경로였거나 파싱 실패), 브라우저에서 온 요청으로 가정
|
||||
if (!jsonBody) {
|
||||
const referer = req.headers.get("referer");
|
||||
if (referer) {
|
||||
return NextResponse.redirect(referer);
|
||||
}
|
||||
const baseUrl = new URL(req.url);
|
||||
baseUrl.pathname = `/posts/${id}`;
|
||||
baseUrl.search = "";
|
||||
return NextResponse.redirect(baseUrl.toString());
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ export async function GET(_: Request, context: { params: Promise<{ id: string }>
|
||||
const post = await prisma.post.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
author: { select: { userId: true, nickname: true } },
|
||||
board: { select: { id: true, name: true, slug: true } },
|
||||
stat: { select: { views: true, recommendCount: true, commentsCount: true } },
|
||||
},
|
||||
});
|
||||
if (!post) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
@@ -5,11 +5,81 @@ import { getUserIdFromRequest } from "@/lib/auth";
|
||||
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await context.params;
|
||||
const userId = getUserIdFromRequest(req);
|
||||
const ip = req.headers.get("x-forwarded-for") || undefined;
|
||||
// x-forwarded-for는 다중 IP가 올 수 있음 -> 첫 번째(실제 클라이언트)만 사용
|
||||
const forwardedFor = req.headers.get("x-forwarded-for") || undefined;
|
||||
const ip = forwardedFor ? forwardedFor.split(",")[0]?.trim() || undefined : undefined;
|
||||
const userAgent = req.headers.get("user-agent") || undefined;
|
||||
await prisma.postViewLog.create({ data: { postId: id, userId: userId ?? null, ip, userAgent } });
|
||||
await prisma.postStat.upsert({ where: { postId: id }, update: { views: { increment: 1 } }, create: { postId: id, views: 1 } });
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
// 중복 방지: 로그인 사용자는 userId로, 비로그인은 (ip + userAgent) 조합으로 1회만 카운트
|
||||
const orConditions: any[] = [];
|
||||
if (userId) {
|
||||
orConditions.push({ userId });
|
||||
}
|
||||
// 비로그인 식별: 가능한 한 많은 신호를 사용 (ip+UA 우선, 단일 ip 또는 단일 UA로도 보수적으로 차단)
|
||||
if (!userId) {
|
||||
if (ip && userAgent) {
|
||||
orConditions.push({ userId: null, ip, userAgent });
|
||||
} else if (ip) {
|
||||
orConditions.push({ userId: null, ip, userAgent: null });
|
||||
} else if (userAgent) {
|
||||
orConditions.push({ userId: null, ip: null, userAgent });
|
||||
}
|
||||
}
|
||||
|
||||
let counted = false;
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (orConditions.length > 0) {
|
||||
const exists = await tx.postViewLog.findFirst({
|
||||
where: {
|
||||
postId: id,
|
||||
OR: orConditions,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 식별 정보가 전혀 없을 때는 안전을 위해 카운트하지 않음
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.postViewLog.create({
|
||||
data: {
|
||||
postId: id,
|
||||
userId: userId ?? null,
|
||||
ip: ip ?? null,
|
||||
userAgent: userAgent ?? null,
|
||||
},
|
||||
});
|
||||
await tx.postStat.upsert({
|
||||
where: { postId: id },
|
||||
update: { views: { increment: 1 } },
|
||||
create: { postId: id, views: 1 },
|
||||
});
|
||||
|
||||
// 일일 조회수 업데이트 (오늘 날짜)
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
await tx.dailyPostView.upsert({
|
||||
where: {
|
||||
postId_date: {
|
||||
postId: id,
|
||||
date: today,
|
||||
},
|
||||
},
|
||||
update: { viewCount: { increment: 1 } },
|
||||
create: {
|
||||
postId: id,
|
||||
date: today,
|
||||
viewCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
counted = true;
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, counted });
|
||||
}
|
||||
|
||||
|
||||
|
||||
92
src/app/api/posts/popular/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const boardId = searchParams.get("boardId");
|
||||
const period = searchParams.get("period") || "daily"; // daily | weekly
|
||||
|
||||
// 날짜 범위 계산
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
||||
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
|
||||
const startOfWeek = new Date(now);
|
||||
startOfWeek.setDate(now.getDate() - 7);
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
const dateFilter = period === "daily"
|
||||
? {
|
||||
date: {
|
||||
gte: startOfToday,
|
||||
lte: endOfToday,
|
||||
}
|
||||
}
|
||||
: { date: { gte: startOfWeek } };
|
||||
|
||||
// 일일 조회수 테이블에서 조회수 합계 계산
|
||||
const dailyViews = await prisma.dailyPostView.groupBy({
|
||||
by: ["postId"],
|
||||
where: dateFilter,
|
||||
_sum: {
|
||||
viewCount: true,
|
||||
},
|
||||
orderBy: {
|
||||
_sum: {
|
||||
viewCount: "desc",
|
||||
},
|
||||
},
|
||||
take: 20, // 조회수 상위 20개만 가져와서 게시글 정보 조회
|
||||
});
|
||||
|
||||
if (dailyViews.length === 0) {
|
||||
return NextResponse.json({ items: [], period });
|
||||
}
|
||||
|
||||
const postIds = dailyViews.map((dv) => dv.postId);
|
||||
|
||||
// 게시글 정보 조회
|
||||
const posts = await prisma.post.findMany({
|
||||
where: {
|
||||
id: { in: postIds },
|
||||
status: "published",
|
||||
...(boardId ? { boardId } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
boardId: true,
|
||||
board: { select: { id: true, name: true, slug: true } },
|
||||
isPinned: true,
|
||||
status: true,
|
||||
author: { select: { userId: true, nickname: true } },
|
||||
stat: { select: { recommendCount: true, views: true, commentsCount: true } },
|
||||
postTags: { select: { tag: { select: { name: true, slug: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
// 조회수와 게시글 매핑
|
||||
const viewCountMap = new Map(
|
||||
dailyViews.map((dv) => [dv.postId, dv._sum.viewCount ?? 0])
|
||||
);
|
||||
|
||||
// 조회수 순으로 정렬 (조회수가 0보다 큰 것만)
|
||||
const postsWithViews = posts
|
||||
.map((post) => ({
|
||||
...post,
|
||||
viewCount: viewCountMap.get(post.id) ?? 0,
|
||||
}))
|
||||
.filter((post) => post.viewCount > 0) // 조회수가 0보다 큰 것만
|
||||
.sort((a, b) => {
|
||||
// 고정글 우선
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
// 조회수 순
|
||||
if (b.viewCount !== a.viewCount) return b.viewCount - a.viewCount;
|
||||
// 최신순
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
})
|
||||
.slice(0, 5); // 상위 5개만
|
||||
|
||||
return NextResponse.json({ items: postsWithViews, period });
|
||||
}
|
||||
@@ -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 } } } },
|
||||
},
|
||||
|
||||
@@ -1,83 +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 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 topUsers = await prisma.user.findMany({
|
||||
select: { userId: true, nickname: true, points: true },
|
||||
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 }));
|
||||
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">
|
||||
{/* 상단 배너 (서브카테고리 표시) */}
|
||||
<section>
|
||||
<HeroBanner
|
||||
subItems={siblingBoards.map((b: any) => ({ id: b.id, name: b.name, href: `/boards/${b.slug}` }))}
|
||||
activeSubId={id}
|
||||
/>
|
||||
</section>
|
||||
{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>
|
||||
{!isSpecialRanking && <BoardToolbar boardId={board?.slug} />}
|
||||
<section className="px-[0px] md:px-[30px] ">
|
||||
{!isSpecialRanking && !isAttendance && <BoardToolbar boardId={board?.slug} />}
|
||||
<div className="p-0">
|
||||
{isSpecialRanking ? (
|
||||
<div className="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">포인트 랭킹</h2>
|
||||
</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 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 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 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>
|
||||
{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}` : ""}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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}
|
||||
<span
|
||||
ref={(el) => {
|
||||
navTextRefs.current[cat.slug] = el;
|
||||
}}
|
||||
className="inline-block"
|
||||
>
|
||||
{cat.name}
|
||||
</span>
|
||||
</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"
|
||||
}`}
|
||||
/>
|
||||
</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,39 +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/>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="ml-3 inline-flex items-center px-3 h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="어드민(임시)"
|
||||
>
|
||||
어드민(임시)
|
||||
</Link>
|
||||
{authData?.user ? (
|
||||
<>
|
||||
{/* 인사 + 로그아웃을 하나의 배지로 묶어 자연스럽게 표시 */}
|
||||
<div className="inline-flex items-center h-10 rounded-md border border-neutral-300 bg-white overflow-hidden">
|
||||
<Link
|
||||
href="/my-page"
|
||||
aria-label="마이페이지"
|
||||
className="h-full px-3 inline-flex items-center text-sm text-neutral-800 hover:bg-neutral-100 cursor-pointer truncate max-w-[220px]"
|
||||
>
|
||||
{authData.user.nickname}님 안녕하세요
|
||||
</Link>
|
||||
<span aria-hidden className="w-px h-5 bg-neutral-200" />
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch("/api/auth/session", { method: "DELETE" });
|
||||
} finally {
|
||||
window.location.reload();
|
||||
}
|
||||
}}
|
||||
className="h-full px-3 text-sm text-neutral-700 hover:bg-neutral-100 focus:outline-none cursor-pointer"
|
||||
aria-label="로그아웃"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
href={`/login?next=${encodeURIComponent((pathname || "/") + (searchParams?.toString() ? `?${searchParams.toString()}` : ""))}`}
|
||||
className="inline-flex items-center px-3 h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="로그인"
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
{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 />
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="inline-flex items-center justify-center h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
||||
aria-label="어드민(임시)"
|
||||
>
|
||||
어드민(임시)
|
||||
</Link>
|
||||
{/* 미니 프로필 패널 */}
|
||||
<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>
|
||||
))}
|
||||
@@ -441,8 +604,7 @@ export function AppHeader() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
246
src/app/components/AttendanceCalendar.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||
|
||||
function getMonthRange(date: Date) {
|
||||
const y = date.getFullYear();
|
||||
const m = date.getMonth(); // 0-based
|
||||
const first = new Date(y, m, 1);
|
||||
const last = new Date(y, m + 1, 0);
|
||||
return { y, m, first, last };
|
||||
}
|
||||
|
||||
export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?: boolean }) {
|
||||
const [current, setCurrent] = React.useState(new Date());
|
||||
const [days, setDays] = React.useState<string[]>([]);
|
||||
const [todayChecked, setTodayChecked] = React.useState<boolean | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [effectiveLoggedIn, setEffectiveLoggedIn] = React.useState<boolean>(!!isLoggedIn);
|
||||
const { show } = useToast();
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const { y, m } = getMonthRange(current);
|
||||
const ymKey = `${y}-${String(m + 1).padStart(2, "0")}`;
|
||||
const today = React.useMemo(() => {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
if (!isLoggedIn) {
|
||||
setDays([]);
|
||||
setTodayChecked(null);
|
||||
setEffectiveLoggedIn(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/attendance?year=${y}&month=${m + 1}`, { cache: "no-store" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!cancelled) {
|
||||
setDays(Array.isArray(data?.days) ? data.days : []);
|
||||
// today === null 이면 비로그인으로 판정
|
||||
const isAuthed = data?.today !== null && data?.today !== undefined;
|
||||
setEffectiveLoggedIn(isAuthed && !!isLoggedIn);
|
||||
setTodayChecked(isAuthed ? !!data?.today : null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [y, m, ymKey, isLoggedIn]);
|
||||
|
||||
const goPrev = () => setCurrent(new Date(current.getFullYear(), current.getMonth() - 1, 1));
|
||||
const goNext = () => setCurrent(new Date(current.getFullYear(), current.getMonth() + 1, 1));
|
||||
|
||||
const handleCheckIn = async () => {
|
||||
if (!effectiveLoggedIn) return;
|
||||
try {
|
||||
const res = await fetch("/api/attendance", { method: "POST" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok && !data?.duplicated) {
|
||||
const d = new Date();
|
||||
const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
setDays((prev) => (prev.includes(s) ? prev : [...prev, s]));
|
||||
setTodayChecked(true);
|
||||
show("출석 완료");
|
||||
} else if (res.ok) {
|
||||
setTodayChecked(true);
|
||||
show("출석 완료");
|
||||
}
|
||||
// 출석 통계/랭킹 리셋(재검증)
|
||||
mutate("/api/attendance/me-stats");
|
||||
mutate("/api/attendance/rankings");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// 캘린더 데이터 생성
|
||||
const firstOfMonth = new Date(y, m, 1);
|
||||
const startWeekday = firstOfMonth.getDay(); // 0:Sun
|
||||
const lastOfMonth = new Date(y, m + 1, 0).getDate();
|
||||
const weeks: Array<Array<{ d: number | null; dateStr?: string; checked?: boolean }>> = [];
|
||||
let day = 1;
|
||||
for (let w = 0; w < 6; w++) {
|
||||
const row: any[] = [];
|
||||
for (let wd = 0; wd < 7; wd++) {
|
||||
if (w === 0 && wd < startWeekday) {
|
||||
row.push({ d: null });
|
||||
} else if (day > lastOfMonth) {
|
||||
row.push({ d: null });
|
||||
} else {
|
||||
const dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
row.push({ d: day, dateStr, checked: days.includes(dateStr) });
|
||||
day++;
|
||||
}
|
||||
}
|
||||
weeks.push(row);
|
||||
if (day > lastOfMonth) break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[520px] mx-auto bg-white rounded-xl border border-neutral-200 p-4">
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<button onClick={goPrev} className="h-8 w-8 rounded-md border border-neutral-300 hover:bg-neutral-50">‹</button>
|
||||
<div className="text-sm font-semibold text-neutral-800">
|
||||
{y}년 {m + 1}월
|
||||
</div>
|
||||
<button onClick={goNext} className="h-8 w-8 rounded-md border border-neutral-300 hover:bg-neutral-50">›</button>
|
||||
</div>
|
||||
<table className="w-full text-center text-sm">
|
||||
<thead className="text-neutral-500">
|
||||
<tr>
|
||||
{["일","월","화","수","목","금","토"].map((h) => (<th key={h} className="py-1">{h}</th>))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{weeks.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{row.map((cell, j) => (
|
||||
<td key={`${i}-${j}`} className="h-9 align-middle">
|
||||
{cell.d ? (
|
||||
(() => {
|
||||
const isToday = cell.dateStr === today;
|
||||
const base = "mx-auto w-8 h-8 leading-8 rounded-full";
|
||||
const color = cell.checked ? " bg-[#F94B37] text-white" : " text-neutral-700";
|
||||
const ring = isToday ? (cell.checked ? " ring-2 ring-offset-2 ring-[#F94B37]" : " ring-2 ring-[#F94B37]") : "";
|
||||
return (
|
||||
<div className={`${base}${color}${ring}`}>
|
||||
{cell.d}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="w-7 h-7 mx-auto" />
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-2 flex items-center justify-center gap-3 text-[11px] text-neutral-600">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-3 rounded-full bg-[#F94B37]" />
|
||||
출석일
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-3 rounded-full ring-2 ring-[#F94B37]" />
|
||||
오늘
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-center">
|
||||
<button
|
||||
onClick={handleCheckIn}
|
||||
disabled={todayChecked === true || loading || !effectiveLoggedIn}
|
||||
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800 disabled:opacity-50"
|
||||
>
|
||||
{todayChecked ? "오늘 출석 완료" : "오늘 출석하기"}
|
||||
</button>
|
||||
</div>
|
||||
{/* 내 출석 통계 */}
|
||||
{effectiveLoggedIn && <MyAttendanceStats />}
|
||||
{!effectiveLoggedIn && (
|
||||
<div className="absolute inset-0 z-20 bg-white/70 backdrop-blur-[1px] rounded-lg flex items-center justify-center">
|
||||
<div className="text-sm font-semibold text-neutral-700">로그인이 필요합니다</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Rankings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MyAttendanceStats() {
|
||||
const { data } = useSWR<{ total: number; currentStreak: number; maxStreak: number }>(
|
||||
"/api/attendance/me-stats",
|
||||
(u) => fetch(u, { cache: "no-store" }).then((r) => r.json())
|
||||
);
|
||||
const total = data?.total ?? 0;
|
||||
const current = data?.currentStreak ?? 0;
|
||||
const max = data?.maxStreak ?? 0;
|
||||
return (
|
||||
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||
<div className="rounded-lg border border-neutral-200 px-3 py-2 text-center">
|
||||
<div className="text-[11px] text-neutral-600">내 출석일수</div>
|
||||
<div className="text-lg font-semibold text-neutral-900">{total}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-neutral-200 px-3 py-2 text-center">
|
||||
<div className="text-[11px] text-neutral-600">현재 연속출석</div>
|
||||
<div className="text-lg font-semibold text-neutral-900">{current}일</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-neutral-200 px-3 py-2 text-center">
|
||||
<div className="text-[11px] text-neutral-600">최대 연속</div>
|
||||
<div className="text-lg font-semibold text-neutral-900">{max}일</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Rankings() {
|
||||
const { data } = useSWR<{ overall?: any[]; streak: any[] }>(
|
||||
"/api/attendance/rankings",
|
||||
(u) => fetch(u, { cache: "no-store" }).then((r) => r.json())
|
||||
);
|
||||
const streak = data?.streak ?? [];
|
||||
const overall = data?.overall ?? [];
|
||||
return (
|
||||
<div className="mt-6 grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg border border-neutral-200">
|
||||
<div className="px-3 py-2 border-b border-neutral-200 text-sm font-semibold">최대 연속출석 순위</div>
|
||||
<ol className="p-3 space-y-2">
|
||||
{streak.length === 0 && <li className="text-xs text-neutral-500">데이터가 없습니다.</li>}
|
||||
{streak.map((u, idx) => (
|
||||
<li key={u.userId} className="flex items-center gap-3 text-sm">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-900 text-white text-xs">{idx + 1}</span>
|
||||
<span className="truncate">{u.nickname}</span>
|
||||
<span className="ml-auto text-xs text-neutral-600">{u.streak}일</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="rounded-lg border border-neutral-200">
|
||||
<div className="px-3 py-2 border-b border-neutral-200 text-sm font-semibold">출석일 순위</div>
|
||||
<ol className="p-3 space-y-2">
|
||||
{overall.length === 0 && <li className="text-xs text-neutral-500">데이터가 없습니다.</li>}
|
||||
{overall.map((u, idx) => (
|
||||
<li key={u.userId} className="flex items-center gap-3 text-sm">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-900 text-white text-xs">{idx + 1}</span>
|
||||
<span className="truncate">{u.nickname}</span>
|
||||
<span className="ml-auto text-xs text-neutral-600">{u.count}일</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
47
src/app/components/AutoLoginAdmin.tsx
Normal 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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { RankIcon1st } from "./RankIcon1st";
|
||||
import { RankIcon2nd } from "./RankIcon2nd";
|
||||
@@ -8,6 +8,7 @@ import { RankIcon3rd } from "./RankIcon3rd";
|
||||
import { UserAvatar } from "./UserAvatar";
|
||||
import { ImagePlaceholderIcon } from "./ImagePlaceholderIcon";
|
||||
import { PostList } from "./PostList";
|
||||
import { GradeIcon } from "./GradeIcon";
|
||||
|
||||
type BoardMeta = {
|
||||
id: string;
|
||||
@@ -21,14 +22,16 @@ type UserData = {
|
||||
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 };
|
||||
stat?: { recommendCount: number | null; commentsCount: number | null };
|
||||
};
|
||||
|
||||
type BoardPanelData = {
|
||||
@@ -40,19 +43,43 @@ type BoardPanelData = {
|
||||
textPosts?: PostData[];
|
||||
};
|
||||
|
||||
export function BoardPanelClient({
|
||||
initialBoardId,
|
||||
boardsData
|
||||
}: {
|
||||
initialBoardId: string;
|
||||
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();
|
||||
@@ -61,6 +88,27 @@ export function BoardPanelClient({
|
||||
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";
|
||||
@@ -69,65 +117,81 @@ export function BoardPanelClient({
|
||||
if (isSpecialRank && selectedBoardData.specialRankUsers) {
|
||||
return (
|
||||
<div className="h-full min-h-0 flex flex-col">
|
||||
<div className="content-stretch flex gap-[30px] items-start w-full mb-2">
|
||||
<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-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div>
|
||||
<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-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 scrollbar-thin-x">
|
||||
<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-[16px] py-[8px] rounded-[14px] text-[14px] shrink-0 cursor-pointer ${
|
||||
sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5]"
|
||||
}`}
|
||||
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">
|
||||
<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-[24px] pt-[8px] pb-[16px]">
|
||||
<div className="flex flex-col gap-[16px]">
|
||||
<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 (
|
||||
<div key={user.userId} className="flex h-[150px] items-center rounded-[16px] overflow-hidden bg-white">
|
||||
<div className="h-[150px] w-[160px] relative shrink-0 bg-[#d5d5d5] overflow-hidden">
|
||||
<UserAvatar
|
||||
src={user.profileImage}
|
||||
alt={user.nickname || "프로필"}
|
||||
width={160}
|
||||
height={150}
|
||||
className="w-full h-full object-cover rounded-none"
|
||||
/>
|
||||
<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-[10px] px-[30px] py-[24px] min-w-0">
|
||||
<div className="flex flex-col gap-[12px] min-w-0 flex-1">
|
||||
<div className="flex items-center gap-[12px]">
|
||||
<div className="relative w-[20px] h-[20px] shrink-0">
|
||||
{rank === 1 && <RankIcon1st />}
|
||||
{rank === 2 && <RankIcon2nd />}
|
||||
{rank === 3 && <RankIcon3rd />}
|
||||
</div>
|
||||
<div className="bg-white border border-[#d5d5d5] px-[16px] py-[5px] rounded-[12px] text-[14px] text-[#5c5c5c] shrink-0">
|
||||
<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-[24px] font-medium text-[#5c5c5c] truncate leading-[22px]">
|
||||
<div className="text-[13px] font-medium text-[#5c5c5c] truncate leading-[16px]">
|
||||
{user.nickname || "익명"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[24px] flex items-center gap-[4px] shrink-0">
|
||||
<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"/>
|
||||
<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-[20px] font-semibold text-[#5c5c5c] leading-[22px]">{user.points.toLocaleString()}</span>
|
||||
<span className="text-[12px] font-semibold text-[#5c5c5c] leading-[16px]">{user.points.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -142,37 +206,45 @@ export function BoardPanelClient({
|
||||
if (isPreview && selectedBoardData.previewPosts) {
|
||||
return (
|
||||
<div className="h-full min-h-0 flex flex-col">
|
||||
<div className="content-stretch flex gap-[30px] items-start w-full mb-2">
|
||||
<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-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div>
|
||||
<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-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 scrollbar-thin-x">
|
||||
<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-[16px] py-[8px] rounded-[14px] text-[14px] shrink-0 cursor-pointer ${
|
||||
sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5]"
|
||||
}`}
|
||||
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="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
|
||||
<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-[24px] pt-[8px] pb-[16px]">
|
||||
<div className="flex flex-col gap-[16px]">
|
||||
<div className="px-[0px] pt-[6px] pb-[6px]">
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
{selectedBoardData.previewPosts.map((post) => {
|
||||
const firstImage = post.attachments?.[0]?.url;
|
||||
// attachments에서 이미지를 먼저 찾고, 없으면 content에서 추출
|
||||
const firstImage = post.attachments?.[0]?.url || extractImageFromContent(post.content);
|
||||
return (
|
||||
<Link key={post.id} href={`/posts/${post.id}`} className="flex h-[150px] items-start rounded-[16px] overflow-hidden bg-white">
|
||||
<div className="h-[150px] w-[214px] relative shrink-0 bg-[#ededed] overflow-hidden">
|
||||
<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={post.title}
|
||||
alt={stripHtml(post.title)}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
@@ -181,20 +253,27 @@ export function BoardPanelClient({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-[10px] px-[30px] py-[24px] min-w-0">
|
||||
<div className="flex flex-col gap-[12px] min-w-0 flex-1">
|
||||
<div className="bg-white border border-[#d5d5d5] px-[16px] py-[5px] rounded-[12px] text-[14px] text-[#5c5c5c] shrink-0 w-fit">
|
||||
<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">
|
||||
<div className="relative w-[16px] h-[16px] shrink-0">
|
||||
<div className="absolute inset-0 rounded-full bg-[#f45f00] opacity-0" />
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
|
||||
</div>
|
||||
<span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate">{post.title}</span>
|
||||
{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-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]">
|
||||
<span className="absolute top-1/2 translate-y-[-50%] text-[10px] leading-[10px] tracking-[-0.24px] text-[#8c8c8c]">
|
||||
{formatDateYmd(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -214,18 +293,27 @@ export function BoardPanelClient({
|
||||
// 텍스트 메인 타입 또는 기본 타입 렌더링
|
||||
return (
|
||||
<div className="h-full min-h-0 flex flex-col">
|
||||
<div className="content-stretch flex gap-[30px] items-start w-full mb-2">
|
||||
<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-[30px] text-[#5c5c5c] leading-[30px]">{categoryName || board.name}</div>
|
||||
<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-[8px] overflow-x-auto flex-nowrap min-w-0 flex-1 scrollbar-thin-x">
|
||||
<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-[16px] py-[8px] rounded-[14px] text-[14px] shrink-0 cursor-pointer ${
|
||||
sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5]"
|
||||
}`}
|
||||
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>
|
||||
@@ -233,25 +321,33 @@ export function BoardPanelClient({
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col bg-white">
|
||||
<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>
|
||||
{!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-[56px] pl-0 pr-[24px] pt-[16px] pb-[16px]">
|
||||
<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">
|
||||
<div className="flex items-center gap-[4px] h-[24px] overflow-hidden">
|
||||
<div className="relative w-[16px] h-[16px] shrink-0">
|
||||
<div className="absolute inset-0 rounded-full bg-[#f45f00] opacity-0" />
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
|
||||
</div>
|
||||
<span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate max-w-[calc(100vw-280px)]">{p.title}</span>
|
||||
<span className="text-[16px] text-[#f45f00] font-bold">+{p.stat?.recommendCount ?? 0}</span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -259,7 +355,7 @@ export function BoardPanelClient({
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<PostList key={board.id} boardId={board.id} sort="recent" />
|
||||
<PostList key={board.id} boardId={board.id} sort="recent" titleHoverOrange pageSizeOverride={16} compact />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React from "react";
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
|
||||
export function BoardToolbar({ boardId }: { boardId: string }) {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const sort = (sp.get("sort") as "recent" | "popular" | null) ?? "recent";
|
||||
const sort = (sp.get("sort") as "recent" | "popular" | "views" | "likes" | "comments" | null) ?? "recent";
|
||||
const scope = (sp.get("scope") as "q" | "author" | null) ?? "q"; // q: 제목+내용, author: 작성자
|
||||
const defaultText = scope === "author" ? sp.get("author") ?? "" : sp.get("q") ?? "";
|
||||
const period = sp.get("period") ?? "all"; // all | 1d | 1w | 1m
|
||||
@@ -16,6 +16,109 @@ export function BoardToolbar({ boardId }: { boardId: string }) {
|
||||
router.push(`/boards/${boardId}?${next.toString()}` , { scroll: false });
|
||||
};
|
||||
|
||||
const pushSort = (value: string) => {
|
||||
const next = new URLSearchParams(sp.toString());
|
||||
next.set("sort", value);
|
||||
router.push(`/boards/${boardId}?${next.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const SortDropdown = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const handler = (ev: MouseEvent) => { if (ref.current && !ref.current.contains(ev.target as Node)) setOpen(false); };
|
||||
window.addEventListener("click", handler);
|
||||
return () => window.removeEventListener("click", handler);
|
||||
}, []);
|
||||
const label =
|
||||
sort === "recent" ? "최신순" : sort === "views" ? "조회순" : sort === "likes" ? "좋아요순" : sort === "comments" ? "댓글순" : "인기순";
|
||||
const items: { value: string; text: string }[] = [
|
||||
{ value: "recent", text: "최신순" },
|
||||
{ value: "views", text: "조회순" },
|
||||
{ value: "likes", text: "좋아요순" },
|
||||
{ value: "comments", text: "댓글순" },
|
||||
];
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex h-12 px-4 items-center justify-center gap-1 rounded-[16px] border border-[#d5d5d5] bg-white text-[14px] text-[#161616] min-w-[93px] cursor-pointer hover:bg-neutral-100"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<path d="M5 8l5 5 5-5" stroke="#707070" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute mt-2 left-0 z-20 bg-white rounded-[16px] p-2 shadow-[0_0_2px_rgba(0,0,0,0.05),0_4px_8px_rgba(0,0,0,0.08)] w-[93px]">
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={it.value}
|
||||
type="button"
|
||||
onClick={() => { setOpen(false); pushSort(it.value); }}
|
||||
className={`w-full px-3 py-2 rounded-[8px] text-[14px] inline-flex items-center justify-center cursor-pointer ${
|
||||
sort === it.value ? "bg-[#707070] text-white" : "text-[#707070] hover:bg-neutral-100"
|
||||
}`}
|
||||
>
|
||||
{it.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ScopeDropdown = ({ value, onSelect }: { value: "q" | "author"; onSelect: (v: "q" | "author") => void }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const handler = (ev: MouseEvent) => { if (ref.current && !ref.current.contains(ev.target as Node)) setOpen(false); };
|
||||
window.addEventListener("click", handler);
|
||||
return () => window.removeEventListener("click", handler);
|
||||
}, []);
|
||||
const label = value === "author" ? "작성자" : "제목+내용";
|
||||
const items: { value: "q" | "author"; text: string }[] = [
|
||||
{ value: "q", text: "제목+내용" },
|
||||
{ value: "author", text: "작성자" },
|
||||
];
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex h-12 px-4 items-center justify-center gap-1 rounded-[16px] border border-[#d5d5d5] bg-white text-[14px] text-[#161616] min-w-[93px] cursor-pointer hover:bg-neutral-100"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<path d="M5 8l5 5 5-5" stroke="#707070" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute mt-2 left-0 z-20 bg-white rounded-[16px] p-2 shadow-[0_0_2px_rgba(0,0,0,0.05),0_4px_8px_rgba(0,0,0,0.08)] w-[120px]">
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={it.value}
|
||||
type="button"
|
||||
onClick={() => { onSelect(it.value); setOpen(false); }}
|
||||
className={`w-full px-3 py-2 rounded-[8px] text-[14px] inline-flex items-center justify-center cursor-pointer ${
|
||||
value === it.value ? "bg-[#707070] text-white" : "text-[#707070] hover:bg-neutral-100"
|
||||
}`}
|
||||
>
|
||||
{it.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const onChangePeriod = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const next = new URLSearchParams(sp.toString());
|
||||
const v = e.target.value;
|
||||
@@ -45,27 +148,71 @@ export function BoardToolbar({ boardId }: { boardId: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-0 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<select aria-label="정렬" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={sort} onChange={onChangeSort}>
|
||||
<option value="recent">최신순</option>
|
||||
<option value="popular">인기순</option>
|
||||
</select>
|
||||
<select aria-label="기간" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={period} onChange={onChangePeriod}>
|
||||
<option value="all">전체기간</option>
|
||||
<option value="1d">24시간</option>
|
||||
<option value="1w">1주일</option>
|
||||
<option value="1m">1개월</option>
|
||||
</select>
|
||||
</div>
|
||||
<form action={onSubmit} className="flex items-center gap-2">
|
||||
<select name="scope" aria-label="검색대상" className="h-8 px-2 rounded-md border border-neutral-300 bg-white text-sm" defaultValue={scope}>
|
||||
<option value="q">제목+내용</option>
|
||||
<option value="author">작성자</option>
|
||||
</select>
|
||||
<input name="text" defaultValue={defaultText} placeholder="검색어를 입력해 주세요." className="h-8 w-56 md:w-72 px-3 rounded-md border border-neutral-300 text-sm placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-neutral-300" />
|
||||
<button type="submit" className="h-8 px-3 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800">검색</button>
|
||||
<div className="px-0 py-2 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
{/* 검색바: 모바일에서는 상단 전체폭 */}
|
||||
<form action={onSubmit} className="order-1 md:order-2 flex items-center gap-2 w-full md:w-auto">
|
||||
<input type="hidden" name="scope" value={scope} />
|
||||
<ScopeDropdown
|
||||
value={scope}
|
||||
onSelect={(v) => {
|
||||
// hidden input을 업데이트하기 위해 URL 파라미터는 변경하지 않고 폼 값만 유지
|
||||
// 검색 제출 시 onSubmit에서 반영됨
|
||||
const form = (document.activeElement as HTMLElement)?.closest("form") as HTMLFormElement | null;
|
||||
if (form) {
|
||||
const input = form.querySelector('input[name="scope"]') as HTMLInputElement | null;
|
||||
if (input) input.value = v;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="relative w-full md:w-96 group">
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="검색 실행"
|
||||
className="absolute right-2 top-2 w-8 h-8 text-neutral-500 cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
className="w-8 h-8 block group-hover:hidden group-focus-within:hidden"
|
||||
>
|
||||
<path
|
||||
d="M21 21L17.682 17.682M17.682 17.682C18.4963 16.8676 19 15.7426 19 14.5C19 12.0147 16.9853 10 14.5 10C12.0147 10 10 12.0147 10 14.5C10 16.9853 12.0147 19 14.5 19C15.7426 19 16.8676 18.4963 17.682 17.682ZM28 16C28 22.6274 22.6274 28 16 28C9.37258 28 4 22.6274 4 16C4 9.37258 9.37258 4 16 4C22.6274 4 28 9.37258 28 16Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
className="w-8 h-8 hidden group-hover:block group-focus-within:block"
|
||||
>
|
||||
<path d="M11 14.5C11 12.567 12.567 11 14.5 11C16.433 11 18 12.567 18 14.5C18 15.4668 17.6093 16.3404 16.9749 16.9749C16.3404 17.6093 15.4668 18 14.5 18C12.567 18 11 16.433 11 14.5Z" fill="#707070" />
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M16 3C8.8203 3 3 8.8203 3 16C3 23.1797 8.8203 29 16 29C23.1797 29 29 23.1797 29 16C29 8.8203 23.1797 3 16 3ZM14.5 9C11.4624 9 9 11.4624 9 14.5C9 17.5376 11.4624 20 14.5 20C15.6571 20 16.7316 19.6419 17.6174 19.0316L20.2929 21.7071C20.6834 22.0976 21.3166 22.0976 21.7071 21.7071C22.0976 21.3166 22.0976 20.6834 21.7071 20.2929L19.0316 17.6174C19.6419 16.7316 20 15.6571 20 14.5C20 11.4624 17.5376 9 14.5 9Z" fill="#707070" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
name="text"
|
||||
defaultValue={defaultText}
|
||||
placeholder="검색어를 입력해 주세요."
|
||||
className="w-full h-12 pr-12 pl-2 rounded-2xl border bg-white border-neutral-300 hover:border-[2px] hover:border-neutral-500 focus:border-2 focus:border-neutral-800 focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 필터: 모바일에서는 검색 아래쪽 */}
|
||||
<div className="order-2 md:order-1 flex items-center gap-2">
|
||||
<SortDropdown />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
368
src/app/components/CommentSection.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
"use client";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { UserAvatar } from "./UserAvatar";
|
||||
import Link from "next/link";
|
||||
import { UserNameMenu } from "./UserNameMenu";
|
||||
|
||||
type CommentAuthor = {
|
||||
userId: string;
|
||||
nickname: string | null;
|
||||
profileImage: string | null;
|
||||
};
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
depth: number;
|
||||
content: string;
|
||||
isAnonymous: boolean;
|
||||
isSecret: boolean;
|
||||
author: CommentAuthor | null;
|
||||
anonId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
replies: Comment[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
postId: string;
|
||||
};
|
||||
|
||||
type CommentItemProps = {
|
||||
comment: Comment;
|
||||
depth?: number;
|
||||
expandedReplies: Set<string>;
|
||||
toggleReplies: (commentId: string) => void;
|
||||
replyingTo: string | null;
|
||||
setReplyingTo: (id: string | null) => void;
|
||||
replyContents: Record<string, string>;
|
||||
setReplyContents: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||
handleSubmitReply: (parentId: string | null) => void;
|
||||
replySecretFlags: Record<string, boolean>;
|
||||
setReplySecretFlags: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||
};
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return "방금 전";
|
||||
if (minutes < 60) return `${minutes}분 전`;
|
||||
if (hours < 24) return `${hours}시간 전`;
|
||||
if (days < 7) return `${days}일 전`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// 개별 댓글 아이템: 부모 렌더 시 타입이 바뀌어 리마운트되지 않도록 파일 최상위에 선언하고 memo 처리
|
||||
const CommentItem = memo(function CommentItem({
|
||||
comment,
|
||||
depth = 0,
|
||||
expandedReplies,
|
||||
toggleReplies,
|
||||
replyingTo,
|
||||
setReplyingTo,
|
||||
replyContents,
|
||||
setReplyContents,
|
||||
handleSubmitReply,
|
||||
replySecretFlags,
|
||||
setReplySecretFlags,
|
||||
}: CommentItemProps) {
|
||||
const canReply = depth < 2; // 최대 3단계까지만
|
||||
const hasReplies = comment.replies && comment.replies.length > 0;
|
||||
const isExpanded = expandedReplies.has(comment.id);
|
||||
const isReplyingHere = replyingTo === comment.id;
|
||||
const replyRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// 답글 폼이 열릴 때만 포커스를 보장
|
||||
useEffect(() => {
|
||||
if (isReplyingHere) {
|
||||
replyRef.current?.focus();
|
||||
}
|
||||
}, [isReplyingHere]);
|
||||
|
||||
return (
|
||||
<div className={`${depth > 0 ? "ml-8 border-l-2 border-neutral-200 pl-4" : ""}`}>
|
||||
<div className="flex gap-3 py-3">
|
||||
<UserAvatar
|
||||
src={comment.author?.profileImage}
|
||||
alt={comment.author?.nickname || "익명"}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-full shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-semibold text-neutral-900">
|
||||
{/* 닉네임 드롭다운 */}
|
||||
{/* 익명 댓글이면 드롭다운 없이 표시 */}
|
||||
{comment.isAnonymous ? (
|
||||
`익명${comment.anonId}`
|
||||
) : (
|
||||
<UserNameMenu userId={comment.author?.userId} nickname={comment.author?.nickname} />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500">{formatDate(comment.createdAt)}</span>
|
||||
{comment.updatedAt !== comment.createdAt && (
|
||||
<span className="text-xs text-neutral-400">(수정됨)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-700 mb-2 whitespace-pre-wrap break-words">
|
||||
{comment.content}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{canReply && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setReplyingTo(comment.id);
|
||||
setReplyContents((prev) => ({ ...prev, [comment.id]: prev[comment.id] ?? "" }));
|
||||
}}
|
||||
className="text-xs text-neutral-500 hover:text-neutral-900"
|
||||
>
|
||||
답글
|
||||
</button>
|
||||
)}
|
||||
{hasReplies && (
|
||||
<button
|
||||
onClick={() => toggleReplies(comment.id)}
|
||||
className="text-xs text-neutral-500 hover:text-neutral-900"
|
||||
>
|
||||
{isExpanded ? "답글 숨기기" : `답글 ${comment.replies.length}개`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 답글 입력 폼 */}
|
||||
{isReplyingHere && (
|
||||
<div className="mt-3 p-3 bg-neutral-50 rounded-lg">
|
||||
<textarea
|
||||
ref={replyRef}
|
||||
value={replyContents[comment.id] ?? ""}
|
||||
onChange={(e) => setReplyContents((prev) => ({ ...prev, [comment.id]: e.target.value }))}
|
||||
placeholder="답글을 입력하세요..."
|
||||
className="w-full p-2 border border-neutral-300 rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-neutral-400"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<label className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!replySecretFlags[comment.id]}
|
||||
onChange={(e) =>
|
||||
setReplySecretFlags((prev) => ({ ...prev, [comment.id]: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
비밀 댓글
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setReplyingTo(null);
|
||||
setReplyContents((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[comment.id];
|
||||
return next;
|
||||
});
|
||||
setReplySecretFlags((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[comment.id];
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="px-3 py-1 text-xs border border-neutral-300 rounded-md hover:bg-neutral-100"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmitReply(comment.id)}
|
||||
className="px-3 py-1 text-xs bg-neutral-900 text-white rounded-md hover:bg-neutral-800"
|
||||
>
|
||||
등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 대댓글 목록 */}
|
||||
{hasReplies && isExpanded && (
|
||||
<div className="mt-2">
|
||||
{comment.replies.map((reply) => (
|
||||
<CommentItem
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
depth={depth + 1}
|
||||
expandedReplies={expandedReplies}
|
||||
toggleReplies={toggleReplies}
|
||||
replyingTo={replyingTo}
|
||||
setReplyingTo={setReplyingTo}
|
||||
replyContents={replyContents}
|
||||
setReplyContents={setReplyContents}
|
||||
handleSubmitReply={handleSubmitReply}
|
||||
replySecretFlags={replySecretFlags}
|
||||
setReplySecretFlags={setReplySecretFlags}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export function CommentSection({ postId }: Props) {
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
||||
const [newContent, setNewContent] = useState("");
|
||||
const [replyContents, setReplyContents] = useState<Record<string, string>>({});
|
||||
const [expandedReplies, setExpandedReplies] = useState<Set<string>>(new Set());
|
||||
const [newIsSecret, setNewIsSecret] = useState(false);
|
||||
const [replySecretFlags, setReplySecretFlags] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadComments();
|
||||
}, [postId]);
|
||||
|
||||
async function loadComments() {
|
||||
try {
|
||||
const res = await fetch(`/api/posts/${postId}/comments`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setComments(data.comments || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitReply(parentId: string | null) {
|
||||
const content = parentId ? (replyContents[parentId] ?? "") : newContent;
|
||||
if (!content.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/comments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
postId,
|
||||
parentId,
|
||||
content,
|
||||
isSecret: parentId ? !!replySecretFlags[parentId] : newIsSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
alert(error.error || "댓글 작성 실패");
|
||||
return;
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
setReplyContents((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[parentId!];
|
||||
return next;
|
||||
});
|
||||
setReplySecretFlags((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[parentId!];
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setNewContent("");
|
||||
setNewIsSecret(false);
|
||||
}
|
||||
setReplyingTo(null);
|
||||
loadComments();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("댓글 작성 실패");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleReplies(commentId: string) {
|
||||
const newExpanded = new Set(expandedReplies);
|
||||
if (newExpanded.has(commentId)) {
|
||||
newExpanded.delete(commentId);
|
||||
} else {
|
||||
newExpanded.add(commentId);
|
||||
}
|
||||
setExpandedReplies(newExpanded);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="rounded-xl overflow-hidden bg-white p-4">
|
||||
<div className="text-center text-neutral-500 py-8">댓글을 불러오는 중...</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-xl overflow-hidden bg-white">
|
||||
<header className="px-4 py-3 border-b border-neutral-200">
|
||||
<h2 className="text-lg font-bold text-neutral-900">댓글 {comments.length}</h2>
|
||||
</header>
|
||||
|
||||
<div className="p-4">
|
||||
{/* 댓글 입력 폼 */}
|
||||
<div className="mb-6 pb-6 border-b border-neutral-200">
|
||||
<textarea
|
||||
value={newContent}
|
||||
onChange={(e) => setNewContent(e.target.value)}
|
||||
placeholder="댓글을 입력하세요..."
|
||||
className="w-full p-3 border border-neutral-300 rounded-lg text-sm resize-none focus:outline-none focus:ring-2 focus:ring-neutral-400"
|
||||
rows={4}
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newIsSecret}
|
||||
onChange={(e) => setNewIsSecret(e.target.checked)}
|
||||
/>
|
||||
비밀 댓글
|
||||
</label>
|
||||
<button
|
||||
onClick={() => handleSubmitReply(null)}
|
||||
disabled={!newContent.trim()}
|
||||
className="px-4 py-2 text-sm bg-neutral-900 text-white rounded-md hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 댓글 목록 */}
|
||||
{comments.length === 0 ? (
|
||||
<div className="text-center text-neutral-500 py-8">댓글이 없습니다.</div>
|
||||
) : (
|
||||
<div className="space-y-0">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
expandedReplies={expandedReplies}
|
||||
toggleReplies={toggleReplies}
|
||||
replyingTo={replyingTo}
|
||||
setReplyingTo={setReplyingTo}
|
||||
replyContents={replyContents}
|
||||
setReplyContents={setReplyContents}
|
||||
handleSubmitReply={handleSubmitReply}
|
||||
replySecretFlags={replySecretFlags}
|
||||
setReplySecretFlags={setReplySecretFlags}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,10 +235,6 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "H2")} aria-label="H2">H2</button>
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "H3")} aria-label="H3">H3</button>
|
||||
<span className="mx-2 h-4 w-px bg-neutral-300" />
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertOrderedList")} aria-label="번호 목록">1.</button>
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertUnorderedList")} aria-label="글머리 목록">•</button>
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => wrapSelectionWithHtml("<code>", "</code>")} aria-label="코드"></></button>
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "BLOCKQUOTE")} aria-label="인용구">❝</button>
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertHorizontalRule")} aria-label="구분선">—</button>
|
||||
<span className="mx-2 h-4 w-px bg-neutral-300" />
|
||||
<button
|
||||
@@ -293,13 +289,6 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
|
||||
<span className="block h-0.5 bg-current" style={{ width: "100%" }} />
|
||||
</span>
|
||||
</button>
|
||||
<span className="mx-2 h-4 w-px bg-neutral-300" />
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("indent")} aria-label="들여쓰기">⇥</button>
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("outdent")} aria-label="내어쓰기">⇤</button>
|
||||
<span className="mx-2 h-4 w-px bg-neutral-300" />
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("removeFormat")} aria-label="서식 제거">clear</button>
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("undo")} aria-label="되돌리기">↶</button>
|
||||
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("redo")} aria-label="다시하기">↷</button>
|
||||
</div>
|
||||
);
|
||||
}, [exec, withToolbar, wrapSelectionWithHtml]);
|
||||
@@ -318,7 +307,7 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
data-placeholder={placeholder}
|
||||
style={{
|
||||
minHeight: 160,
|
||||
minHeight: 500,
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
|
||||
45
src/app/components/GradeIcon.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
export function GradeIcon({ grade, width = 32, height = 32, className }: { grade: number; width?: number; height?: number; className?: string }) {
|
||||
// grade: 0~7 (bronze, silver, gold, platinum, diamond, master, grandmaster, god)
|
||||
const gradeImages = [
|
||||
"/svgs/01_bronze.svg",
|
||||
"/svgs/02_silver.svg.svg",
|
||||
"/svgs/03_gold.svg",
|
||||
"/svgs/04_platinum.svg",
|
||||
"/svgs/05_diamond.svg",
|
||||
"/svgs/06_master.svg",
|
||||
"/svgs/07_grandmaster.svg",
|
||||
"/svgs/08_god.svg",
|
||||
];
|
||||
|
||||
const gradeIndex = Math.min(7, Math.max(0, grade));
|
||||
const imageSrc = gradeImages[gradeIndex];
|
||||
|
||||
return (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={`등급 ${gradeIndex + 1}`}
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
style={{ width, height }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 등급 숫자를 등급 이름으로 변환하는 함수
|
||||
export function getGradeName(grade: number): string {
|
||||
const gradeNames = [
|
||||
"Bronze",
|
||||
"Silver",
|
||||
"Gold",
|
||||
"Platinum",
|
||||
"Diamond",
|
||||
"Master",
|
||||
"Grandmaster",
|
||||
"God",
|
||||
];
|
||||
|
||||
const gradeIndex = Math.min(7, Math.max(0, grade));
|
||||
return gradeNames[gradeIndex];
|
||||
}
|
||||
|
||||
@@ -10,7 +10,38 @@ type SubItem = { id: string; name: string; href: string };
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; activeSubId?: string }) {
|
||||
export function HeroBanner({ subItems, activeSubId, hideSubOnMobile, showPartnerCats }: { subItems?: SubItem[]; activeSubId?: string; hideSubOnMobile?: boolean; showPartnerCats?: boolean }) {
|
||||
const usePartnerCats = ((!Array.isArray(subItems) || subItems.length === 0) && showPartnerCats !== false);
|
||||
// 파트너 카테고리 불러오기 (홈 배너 하단 블랙바에 표시)
|
||||
const { data: catData } = useSWR<{ categories: any[] }>(usePartnerCats ? "/api/partner-categories" : null, fetcher, { revalidateOnFocus: false, dedupingInterval: 5 * 60 * 1000 });
|
||||
const categories = catData?.categories ?? [];
|
||||
const [selectedCatId, setSelectedCatId] = useState<string>("");
|
||||
useEffect(() => {
|
||||
if (!usePartnerCats) return;
|
||||
if (!selectedCatId && categories.length > 0) {
|
||||
let id = "";
|
||||
try {
|
||||
id = (window as any).__partnerCategoryId || localStorage.getItem("selectedPartnerCategoryId") || "";
|
||||
} catch {}
|
||||
if (!id) id = categories[0].id;
|
||||
setSelectedCatId(id);
|
||||
try {
|
||||
(window as any).__partnerCategoryId = id;
|
||||
localStorage.setItem("selectedPartnerCategoryId", id);
|
||||
window.dispatchEvent(new CustomEvent("partnerCategorySelect", { detail: { id } }));
|
||||
} catch {}
|
||||
}
|
||||
}, [usePartnerCats, categories, selectedCatId]);
|
||||
const onSelectCategory = useCallback((id: string) => {
|
||||
if (!usePartnerCats) return;
|
||||
setSelectedCatId(id);
|
||||
// 전역 이벤트로 선택 전달
|
||||
try {
|
||||
(window as any).__partnerCategoryId = id;
|
||||
localStorage.setItem("selectedPartnerCategoryId", id);
|
||||
window.dispatchEvent(new CustomEvent("partnerCategorySelect", { detail: { id } }));
|
||||
} catch {}
|
||||
}, [usePartnerCats]);
|
||||
// SWR 캐시 사용: 페이지 전환 시 재요청/깜빡임 최소화
|
||||
const { data } = useSWR<{ banners: Banner[] }>("/api/banners", fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
@@ -78,23 +109,46 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
|
||||
<section className="relative w-full overflow-hidden rounded-xl bg-neutral-900 text-white" aria-roledescription="carousel">
|
||||
<SelectedBanner className="h-56 sm:h-72 md:h-[264px]" />
|
||||
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 (스켈레톤 상태에서도 동일 레이아웃 유지) */}
|
||||
<div className="h-[58px] bg-black rounded-xl flex items-center justify-center px-2">
|
||||
{Array.isArray(subItems) && subItems.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-[8px]">
|
||||
{subItems.map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={s.href}
|
||||
<div className={`${hideSubOnMobile ? "hidden md:flex" : "flex"} h-[58px] bg-black rounded-xl items-end justify-center px-2`}>
|
||||
{usePartnerCats ? (
|
||||
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||
{categories.map((c: any) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => onSelectCategory(c.id)}
|
||||
className={
|
||||
s.id === activeSubId
|
||||
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
|
||||
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:text-white border border-white/30 text-[12px] leading-[28px] whitespace-nowrap"
|
||||
(selectedCatId || categories[0]?.id) === c.id
|
||||
? "px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap"
|
||||
: "px-3 h-full inline-flex items-center bg-transparent text-[#d5d5d5] hover:bg-white hover:text-[#d73b29] hover:font-[700] hover:rounded-tl-[14px] hover:rounded-tr-[14px] text-[20px] font-[500] whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
Array.isArray(subItems) && subItems.length > 0 && (
|
||||
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||
{subItems.map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={s.href}
|
||||
className={
|
||||
s.id === activeSubId
|
||||
? "px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap"
|
||||
: "px-3 h-full inline-flex items-center bg-transparent text-[#d5d5d5] hover:bg-white hover:text-[#d73b29] hover:font-[700] hover:rounded-tl-[14px] hover:rounded-tr-[14px] text-[20px] font-[500] whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && (
|
||||
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||
<span className="px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap">암실소문</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -130,6 +184,32 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 좌우 내비게이션 버튼 */}
|
||||
{numSlides > 1 && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-between px-1 sm:px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
aria-label="이전 배너"
|
||||
className="pointer-events-auto inline-flex items-center justify-center h-8 w-8 text-white/80 hover:text-[var(--red-50,#F94B37)] transition-colors focus:outline-none"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5 15L7.5 10L12.5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
aria-label="다음 배너"
|
||||
className="pointer-events-auto inline-flex items-center justify-center h-8 w-8 text-white/80 hover:text-[var(--red-50,#F94B37)] transition-colors focus:outline-none"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.5 5L12.5 10L7.5 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination - Figma 스타일: 활성은 주황 바, 비활성은 회색 점 (배너 위에 오버랩) */}
|
||||
{numSlides > 1 && (
|
||||
<div className="pointer-events-auto absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-[10px]">
|
||||
@@ -151,23 +231,46 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
|
||||
</div>
|
||||
|
||||
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 */}
|
||||
<div className="h-[58px] bg-black rounded-xl flex items-center justify-center px-2">
|
||||
{Array.isArray(subItems) && subItems.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-[8px]">
|
||||
{subItems.map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={s.href}
|
||||
<div className={`${hideSubOnMobile ? "hidden md:flex" : "flex"} h-[58px] bg-black rounded-xl items-end justify-center px-2`}>
|
||||
{usePartnerCats ? (
|
||||
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||
{categories.map((c: any) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => onSelectCategory(c.id)}
|
||||
className={
|
||||
s.id === activeSubId
|
||||
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
|
||||
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:text-white border border-white/30 text-[12px] leading-[28px] whitespace-nowrap"
|
||||
(selectedCatId || categories[0]?.id) === c.id
|
||||
? "px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap"
|
||||
: "px-3 h-full inline-flex items-center bg-transparent text-[#d5d5d5] hover:bg-white hover:text-[#d73b29] hover:font-[700] hover:rounded-tl-[14px] hover:rounded-tr-[14px] text-[20px] font-[500] whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
Array.isArray(subItems) && subItems.length > 0 && (
|
||||
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||
{subItems.map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={s.href}
|
||||
className={
|
||||
s.id === activeSubId
|
||||
? "px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap"
|
||||
: "px-3 h-full inline-flex items-center bg-transparent text-[#d5d5d5] hover:bg-white hover:text-[#d73b29] hover:font-[700] hover:rounded-tl-[14px] hover:rounded-tr-[14px] text-[20px] font-[500] whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{!usePartnerCats && (!Array.isArray(subItems) || subItems.length === 0) && (
|
||||
<div className="flex flex-wrap items-center h-[74%] gap-[24px]">
|
||||
<span className="px-3 h-full inline-flex items-center bg-white text-[#d73b29] text-[20px] font-[700] rounded-tl-[14px] rounded-tr-[14px] whitespace-nowrap">암실소문</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ export default function HorizontalCardScroller({ items }: HorizontalCardScroller
|
||||
const CARD_WIDTH = 384;
|
||||
const CARD_GAP = 16; // Tailwind gap-4
|
||||
const SCROLL_STEP = CARD_WIDTH + CARD_GAP;
|
||||
useEffect(() => {
|
||||
console.log(items);
|
||||
}, [items]);
|
||||
|
||||
const updateThumb = useCallback(() => {
|
||||
const scroller = scrollRef.current;
|
||||
@@ -103,11 +106,7 @@ export default function HorizontalCardScroller({ items }: HorizontalCardScroller
|
||||
{items.map((card) => (
|
||||
<article
|
||||
key={card.id}
|
||||
className="flex-shrink-0 w-[384px] h-[308px] rounded-[16px] bg-white overflow-hidden"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 1px 2px 0 var(--color-alpha-shadow1, rgba(0, 0, 0, 0.05)), 0 0 2px 0 var(--color-alpha-shadow1, rgba(0, 0, 0, 0.05))",
|
||||
}}
|
||||
className="flex-shrink-0 w-[384px] h-[308px] rounded-[16px] bg-white overflow-hidden shadow-[0_1px_2px_rgba(0,0,0,0.05),0_0_2px_rgba(0,0,0,0.05)] transition-shadow duration-200 hover:shadow-[0_0_2px_rgba(0,0,0,0.08),0_8px_16px_rgba(0,0,0,0.12)]"
|
||||
>
|
||||
<div className="grid grid-rows-[192px_116px] h-full">
|
||||
{/* 상단: 사진 384x192, 상단 라운드 16, 하단 0 */}
|
||||
@@ -149,20 +148,20 @@ export default function HorizontalCardScroller({ items }: HorizontalCardScroller
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute bottom-[20px] left-1/2 -translate-x-1/2 z-20 flex items-center gap-3">
|
||||
<div className="pointer-events-none absolute bottom-[-5px] left-1/2 -translate-x-1/2 z-20 flex items-center justify-center gap-6 w-full">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="이전"
|
||||
className="pointer-events-auto p-0 m-0 bg-transparent text-orange-500 hover:text-orange-600 focus:outline-none"
|
||||
className="pointer-events-auto p-0 m-0 bg-transparent text-neutral-500 hover:text-neutral-700 focus:outline-none size-[30px] inline-flex items-center justify-center"
|
||||
onClick={() => scrollByStep(-1)}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
|
||||
<polygon points="10,0 0,6 10,12" />
|
||||
<svg width="30" height="30" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="rotate-180">
|
||||
<path d="M9.5 5.5L15 12l-5.5 6.5" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div ref={trackRef} className="pointer-events-auto relative h-2 w-[30vw] rounded-full bg-orange-200/50">
|
||||
<div ref={trackRef} className="pointer-events-auto relative h-1 flex-1 min-w-0 max-w-[480px] rounded bg-[#EDEDED]">
|
||||
<div
|
||||
className="absolute top-0 h-2 rounded-full bg-orange-500"
|
||||
className="absolute top-0 h-1 rounded bg-[var(--red-50,#F94B37)]"
|
||||
style={{ width: `${thumbWidth}px`, left: `${thumbLeft}px` }}
|
||||
onMouseDown={handleThumbMouseDown}
|
||||
/>
|
||||
@@ -170,11 +169,11 @@ export default function HorizontalCardScroller({ items }: HorizontalCardScroller
|
||||
<button
|
||||
type="button"
|
||||
aria-label="다음"
|
||||
className="pointer-events-auto p-0 m-0 bg-transparent text-orange-500 hover:text-orange-600 focus:outline-none"
|
||||
className="pointer-events-auto p-0 m-0 bg-transparent text-neutral-500 hover:text-neutral-700 focus:outline-none size-[30px] inline-flex items-center justify-center"
|
||||
onClick={() => scrollByStep(1)}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
|
||||
<polygon points="0,0 10,6 0,12" />
|
||||
<svg width="30" height="30" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M9.5 5.5L15 12l-5.5 6.5" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
69
src/app/components/InlineLoginForm.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function InlineLoginForm({ next = "/" }: { next?: string }) {
|
||||
const [nickname, setNickname] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: nickname, password }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.error || "로그인 실패");
|
||||
// 성공 시 다음 경로로 이동 (기본은 홈)
|
||||
window.location.href = next || "/";
|
||||
} catch (err: any) {
|
||||
const raw = err?.message;
|
||||
const fallback = "아이디 또는 비밀번호가 일치하지 않습니다";
|
||||
const msg =
|
||||
typeof raw === "string" && raw !== "[object Object]" && raw.trim().length > 0
|
||||
? raw
|
||||
: fallback;
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="w-[300px] flex flex-col gap-2">
|
||||
<input
|
||||
placeholder="아이디"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
className="h-10 rounded-md border border-neutral-300 px-3 text-sm"
|
||||
/>
|
||||
<input
|
||||
placeholder="비밀번호"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="h-10 rounded-md border border-neutral-300 px-3 text-sm"
|
||||
/>
|
||||
{error && <div className="text-xs text-red-600 mt-1">{error}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full h-[40px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[14px] font-[700] flex items-center justify-center disabled:opacity-60"
|
||||
>
|
||||
{loading ? "로그인 중..." : "로그인"}
|
||||
</button>
|
||||
<div className="flex justify-end">
|
||||
<Link href="/register" className="text-[13px] text-[#5c5c5c] hover:underline">회원가입</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||