Compare commits
1 Commits
main
...
98e2f5608d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98e2f5608d |
@@ -1,18 +1,17 @@
|
|||||||
|
배너 디테일
|
||||||
|
카드 디테일
|
||||||
|
메인 디테일 프리뷰, 글, 스페셜_랭크
|
||||||
|
|
||||||
|
기본 리스트 , 글이 없습니다.
|
||||||
|
글쓰기
|
||||||
|
글뷰, 댓글 +리스트
|
||||||
|
|
||||||
x 메인 게시판 일반
|
|
||||||
메인 게시판 프리뷰
|
|
||||||
x 메인 게시판 스페셜랭크
|
|
||||||
|
|
||||||
기본 리스트
|
|
||||||
스페셜_랭크
|
스페셜_랭크
|
||||||
스페셜_출석
|
스페셜_출석
|
||||||
스페셜_제휴업체
|
스페셜_제휴업체
|
||||||
스페셜_제휴업체지도
|
스페셜_제휴업체지도
|
||||||
|
|
||||||
게시글 뷰 + 댓글
|
|
||||||
|
|
||||||
로그인관련
|
로그인관련
|
||||||
회원가입 페이지
|
|
||||||
회원쪽지
|
회원쪽지
|
||||||
링크로들어오면 보이고 거기서 페이지 이동하면 안보이게
|
링크로들어오면 보이고 거기서 페이지이동하면안보이게
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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.이미지 사이즈 미리불러오기
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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,4 +46,3 @@ next-env.d.ts
|
|||||||
/prisma/prisma/dev.db
|
/prisma/prisma/dev.db
|
||||||
|
|
||||||
*.ignore
|
*.ignore
|
||||||
/logs
|
|
||||||
|
|||||||
13
.runner
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
test
|
|
||||||
new address check
|
|
||||||
new address check2
|
|
||||||
deploy test2dfdf213
|
|
||||||
deploy test001
|
|
||||||
deploy test002
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
name: 'assm',
|
|
||||||
cwd: '/root/msgapp',
|
|
||||||
script: 'npm',
|
|
||||||
args: 'start',
|
|
||||||
interpreter: 'none',
|
|
||||||
env: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
},
|
|
||||||
autorestart: true,
|
|
||||||
watch: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { middleware, config } from "./src/middleware";
|
|
||||||
|
|
||||||
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build --turbopack",
|
"build": "next build --turbopack",
|
||||||
"start": "next start --port 3100",
|
"start": "next start",
|
||||||
"lint": "biome check",
|
"lint": "biome check",
|
||||||
"format": "biome format --write",
|
"format": "biome format --write",
|
||||||
"migrate": "prisma migrate dev",
|
"migrate": "prisma migrate dev",
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `requiresApproval` on the `boards` table. All the data in the column will be lost.
|
|
||||||
- You are about to drop the column `type` on the `boards` table. All the data in the column will be lost.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- RedefineTables
|
|
||||||
PRAGMA defer_foreign_keys=ON;
|
|
||||||
PRAGMA foreign_keys=OFF;
|
|
||||||
CREATE TABLE "new_boards" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"slug" TEXT NOT NULL,
|
|
||||||
"description" TEXT,
|
|
||||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"status" TEXT NOT NULL DEFAULT 'active',
|
|
||||||
"allowAnonymousPost" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"allowSecretComment" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"isAdultOnly" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"requiredTags" JSONB,
|
|
||||||
"requiredFields" JSONB,
|
|
||||||
"readLevel" TEXT NOT NULL DEFAULT 'public',
|
|
||||||
"writeLevel" TEXT NOT NULL DEFAULT 'member',
|
|
||||||
"categoryId" TEXT,
|
|
||||||
"mainPageViewTypeId" TEXT,
|
|
||||||
"listViewTypeId" TEXT,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL,
|
|
||||||
CONSTRAINT "boards_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "board_categories" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
|
||||||
CONSTRAINT "boards_mainPageViewTypeId_fkey" FOREIGN KEY ("mainPageViewTypeId") REFERENCES "board_view_types" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
|
||||||
CONSTRAINT "boards_listViewTypeId_fkey" FOREIGN KEY ("listViewTypeId") REFERENCES "board_view_types" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
INSERT INTO "new_boards" ("allowAnonymousPost", "allowSecretComment", "categoryId", "createdAt", "description", "id", "isAdultOnly", "listViewTypeId", "mainPageViewTypeId", "name", "readLevel", "requiredFields", "requiredTags", "slug", "sortOrder", "status", "updatedAt", "writeLevel") SELECT "allowAnonymousPost", "allowSecretComment", "categoryId", "createdAt", "description", "id", "isAdultOnly", "listViewTypeId", "mainPageViewTypeId", "name", "readLevel", "requiredFields", "requiredTags", "slug", "sortOrder", "status", "updatedAt", "writeLevel" FROM "boards";
|
|
||||||
DROP TABLE "boards";
|
|
||||||
ALTER TABLE "new_boards" RENAME TO "boards";
|
|
||||||
CREATE UNIQUE INDEX "boards_slug_key" ON "boards"("slug");
|
|
||||||
CREATE INDEX "boards_status_sortOrder_idx" ON "boards"("status", "sortOrder");
|
|
||||||
CREATE INDEX "boards_categoryId_idx" ON "boards"("categoryId");
|
|
||||||
CREATE INDEX "boards_mainPageViewTypeId_idx" ON "boards"("mainPageViewTypeId");
|
|
||||||
CREATE INDEX "boards_listViewTypeId_idx" ON "boards"("listViewTypeId");
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
PRAGMA defer_foreign_keys=OFF;
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
-- 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,7 +217,6 @@ model Post {
|
|||||||
reports Report[]
|
reports Report[]
|
||||||
stat PostStat?
|
stat PostStat?
|
||||||
viewLogs PostViewLog[]
|
viewLogs PostViewLog[]
|
||||||
dailyViews DailyPostView[]
|
|
||||||
|
|
||||||
@@index([boardId, status, createdAt])
|
@@index([boardId, status, createdAt])
|
||||||
@@index([boardId, isPinned, pinnedOrder])
|
@@index([boardId, isPinned, pinnedOrder])
|
||||||
@@ -226,12 +225,12 @@ model Post {
|
|||||||
|
|
||||||
// 사용자
|
// 사용자
|
||||||
model User {
|
model User {
|
||||||
userId String @id
|
userId String @id @default(cuid())
|
||||||
nickname String @unique
|
nickname String @unique
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
name String
|
name String
|
||||||
birth DateTime?
|
birth DateTime
|
||||||
phone String? @unique
|
phone String @unique
|
||||||
rank Int @default(0)
|
rank Int @default(0)
|
||||||
// 누적 포인트, 레벨, 등급(0~10)
|
// 누적 포인트, 레벨, 등급(0~10)
|
||||||
points Int @default(0)
|
points Int @default(0)
|
||||||
@@ -260,7 +259,6 @@ model User {
|
|||||||
blocksInitiated Block[] @relation("Blocker")
|
blocksInitiated Block[] @relation("Blocker")
|
||||||
blocksReceived Block[] @relation("Blocked")
|
blocksReceived Block[] @relation("Blocked")
|
||||||
pointTxns PointTransaction[]
|
pointTxns PointTransaction[]
|
||||||
attendances Attendance[]
|
|
||||||
sanctions Sanction[]
|
sanctions Sanction[]
|
||||||
nicknameChanges NicknameChange[]
|
nicknameChanges NicknameChange[]
|
||||||
passwordResetTokens PasswordResetToken[] @relation("PasswordResetUser")
|
passwordResetTokens PasswordResetToken[] @relation("PasswordResetUser")
|
||||||
@@ -326,8 +324,6 @@ model UserRole {
|
|||||||
model Comment {
|
model Comment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
postId String
|
postId String
|
||||||
parentId String? // 부모 댓글 ID (null이면 최상위 댓글)
|
|
||||||
depth Int @default(0) // 댓글 깊이 (0=최상위, 1=1단계 대댓글, 2=2단계 대댓글)
|
|
||||||
authorId String?
|
authorId String?
|
||||||
content String
|
content String
|
||||||
isAnonymous Boolean @default(false)
|
isAnonymous Boolean @default(false)
|
||||||
@@ -337,13 +333,10 @@ model Comment {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
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)
|
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
|
||||||
reports Report[]
|
reports Report[]
|
||||||
|
|
||||||
@@index([postId, createdAt])
|
@@index([postId, createdAt])
|
||||||
@@index([parentId])
|
|
||||||
@@map("comments")
|
@@map("comments")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,21 +432,6 @@ model PostStat {
|
|||||||
@@map("post_stats")
|
@@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 {
|
model Report {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -523,20 +501,6 @@ model PointTransaction {
|
|||||||
@@map("point_transactions")
|
@@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 {
|
model LevelThreshold {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -705,7 +669,6 @@ model Partner {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
category String
|
category String
|
||||||
categoryId String?
|
|
||||||
latitude Float
|
latitude Float
|
||||||
longitude Float
|
longitude Float
|
||||||
address String?
|
address String?
|
||||||
@@ -714,27 +677,11 @@ model Partner {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
categoryRef PartnerCategory? @relation(fields: [categoryId], references: [id])
|
|
||||||
|
|
||||||
@@index([category])
|
@@index([category])
|
||||||
@@index([categoryId])
|
|
||||||
@@index([sortOrder])
|
@@index([sortOrder])
|
||||||
@@map("partners")
|
@@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 {
|
model Banner {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -777,10 +724,6 @@ model PartnerRequest {
|
|||||||
longitude Float
|
longitude Float
|
||||||
address String?
|
address String?
|
||||||
contact String?
|
contact String?
|
||||||
region String?
|
|
||||||
imageUrl String?
|
|
||||||
sortOrder Int @default(0)
|
|
||||||
active Boolean @default(true)
|
|
||||||
status String @default("pending") // pending/approved/rejected
|
status String @default("pending") // pending/approved/rejected
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
approvedAt DateTime?
|
approvedAt DateTime?
|
||||||
@@ -801,4 +744,18 @@ model Setting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 메인 노출용 제휴 샵 가로 스크롤 데이터
|
// 메인 노출용 제휴 샵 가로 스크롤 데이터
|
||||||
// PartnerShop 모델 제거됨 (PartnerRequest로 통합)
|
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")
|
||||||
|
}
|
||||||
|
|||||||
303
prisma/seed.js
@@ -1,12 +1,7 @@
|
|||||||
const { PrismaClient } = require("@prisma/client");
|
const { PrismaClient } = require("@prisma/client");
|
||||||
const { createHash } = require("crypto");
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
function hashPassword(plain) {
|
|
||||||
return createHash("sha256").update(plain, "utf8").digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomInt(min, max) {
|
function randomInt(min, max) {
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
}
|
}
|
||||||
@@ -37,75 +32,6 @@ function generateNickname(i) {
|
|||||||
return `user${String(i + 1).padStart(3, "0")}${suffix}`;
|
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) {
|
async function createRandomUsers(count = 100) {
|
||||||
const roleUser = await prisma.role.findUnique({ where: { name: "user" } });
|
const roleUser = await prisma.role.findUnique({ where: { name: "user" } });
|
||||||
// 사용되지 않은 전화번호를 찾는 보조 함수
|
// 사용되지 않은 전화번호를 찾는 보조 함수
|
||||||
@@ -119,48 +45,27 @@ async function createRandomUsers(count = 100) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdUsers = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
// 고정 ID: user001, user002, ...
|
// 닉네임을 결정론적으로 생성해 재실행 시 중복 생성 방지
|
||||||
const userId = `user${String(i + 1).padStart(3, "0")}`;
|
const nickname = `user${String(i + 1).padStart(3, "0")}`;
|
||||||
const existing = await prisma.user.findUnique({ where: { userId } });
|
const existing = await prisma.user.findUnique({ where: { nickname } });
|
||||||
let user = existing;
|
let user = existing;
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const name = generateRandomKoreanName();
|
const name = generateRandomKoreanName();
|
||||||
const birth = randomDate(1975, 2005);
|
const birth = randomDate(1975, 2005);
|
||||||
const phone = await findAvailablePhone(i + 2); // admin이 0001 사용하므로 겹치지 않도록 오프셋
|
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({
|
user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
|
||||||
nickname,
|
nickname,
|
||||||
name,
|
name,
|
||||||
birth,
|
birth,
|
||||||
phone,
|
phone,
|
||||||
passwordHash: hashPassword("12341234"),
|
|
||||||
agreementTermsAt: new Date(),
|
agreementTermsAt: new Date(),
|
||||||
authLevel: "USER",
|
authLevel: "USER",
|
||||||
isAdultVerified: Math.random() < 0.6,
|
isAdultVerified: Math.random() < 0.6,
|
||||||
lastLoginAt: Math.random() < 0.8 ? new Date() : null,
|
lastLoginAt: Math.random() < 0.8 ? new Date() : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// 기존 사용자도 패스워드를 1234로 업데이트
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { userId: user.userId },
|
|
||||||
data: { passwordHash: hashPassword("12341234") },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (roleUser && user) {
|
if (roleUser && user) {
|
||||||
await prisma.userRole.upsert({
|
await prisma.userRole.upsert({
|
||||||
@@ -169,9 +74,7 @@ async function createRandomUsers(count = 100) {
|
|||||||
create: { userId: user.userId, roleId: roleUser.roleId },
|
create: { userId: user.userId, roleId: roleUser.roleId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (user) createdUsers.push(user);
|
|
||||||
}
|
}
|
||||||
return createdUsers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertCategories() {
|
async function upsertCategories() {
|
||||||
@@ -252,24 +155,14 @@ async function upsertRoles() {
|
|||||||
async function upsertAdmin() {
|
async function upsertAdmin() {
|
||||||
const admin = await prisma.user.upsert({
|
const admin = await prisma.user.upsert({
|
||||||
where: { nickname: "admin" },
|
where: { nickname: "admin" },
|
||||||
update: {
|
update: {},
|
||||||
passwordHash: hashPassword("12341234"),
|
|
||||||
grade: 7,
|
|
||||||
points: 1650000,
|
|
||||||
level: 200,
|
|
||||||
},
|
|
||||||
create: {
|
create: {
|
||||||
userId: "admin",
|
|
||||||
nickname: "admin",
|
nickname: "admin",
|
||||||
name: "Administrator",
|
name: "Administrator",
|
||||||
birth: new Date("1990-01-01"),
|
birth: new Date("1990-01-01"),
|
||||||
phone: "010-0000-0001",
|
phone: "010-0000-0001",
|
||||||
passwordHash: hashPassword("12341234"),
|
|
||||||
agreementTermsAt: new Date(),
|
agreementTermsAt: new Date(),
|
||||||
authLevel: "ADMIN",
|
authLevel: "ADMIN",
|
||||||
grade: 7,
|
|
||||||
points: 1650000,
|
|
||||||
level: 200,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -286,55 +179,6 @@ async function upsertAdmin() {
|
|||||||
return admin;
|
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) {
|
async function upsertBoards(admin, categoryMap) {
|
||||||
const boards = [
|
const boards = [
|
||||||
// 일반
|
// 일반
|
||||||
@@ -355,12 +199,9 @@ async function upsertBoards(admin, categoryMap) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const created = [];
|
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 mainSpecial = await prisma.boardViewType.findUnique({ where: { key: "main_special_rank" } });
|
||||||
const listSpecial = await prisma.boardViewType.findUnique({ where: { key: "list_special_rank" } });
|
const listSpecial = await prisma.boardViewType.findUnique({ where: { key: "list_special_rank" } });
|
||||||
const listSpecialAttendance = await prisma.boardViewType.findUnique({ where: { key: "list_special_attendance" } });
|
|
||||||
|
|
||||||
for (const b of boards) {
|
for (const b of boards) {
|
||||||
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
|
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
|
||||||
@@ -392,12 +233,8 @@ async function upsertBoards(admin, categoryMap) {
|
|||||||
allowAnonymousPost: !!b.allowAnonymousPost,
|
allowAnonymousPost: !!b.allowAnonymousPost,
|
||||||
readLevel: b.readLevel || undefined,
|
readLevel: b.readLevel || undefined,
|
||||||
categoryId: category ? category.id : undefined,
|
categoryId: category ? category.id : undefined,
|
||||||
// 기본은 텍스트, 'ranking'은 특수랭킹, 'attendance'는 특수출석으로 오버라이드
|
|
||||||
...(mainText ? { mainPageViewTypeId: mainText.id } : {}),
|
|
||||||
...(listText ? { listViewTypeId: listText.id } : {}),
|
|
||||||
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
|
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
|
||||||
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
||||||
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
|
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
name: b.name,
|
name: b.name,
|
||||||
@@ -407,12 +244,8 @@ async function upsertBoards(admin, categoryMap) {
|
|||||||
allowAnonymousPost: !!b.allowAnonymousPost,
|
allowAnonymousPost: !!b.allowAnonymousPost,
|
||||||
readLevel: b.readLevel || undefined,
|
readLevel: b.readLevel || undefined,
|
||||||
categoryId: category ? category.id : undefined,
|
categoryId: category ? category.id : undefined,
|
||||||
// 기본은 텍스트, 'ranking'은 특수랭킹, 'attendance'는 특수출석으로 오버라이드
|
|
||||||
...(mainText ? { mainPageViewTypeId: mainText.id } : {}),
|
|
||||||
...(listText ? { listViewTypeId: listText.id } : {}),
|
|
||||||
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
|
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
|
||||||
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
||||||
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
created.push(board);
|
created.push(board);
|
||||||
@@ -433,15 +266,16 @@ async function upsertBoards(admin, categoryMap) {
|
|||||||
|
|
||||||
async function upsertViewTypes() {
|
async function upsertViewTypes() {
|
||||||
const viewTypes = [
|
const viewTypes = [
|
||||||
// main scope (기본/없음 제거, 텍스트 중심)
|
// main scope
|
||||||
|
{ key: "main_default", name: "기본", scope: "main" },
|
||||||
{ key: "main_text", name: "텍스트", scope: "main" },
|
{ key: "main_text", name: "텍스트", scope: "main" },
|
||||||
{ key: "main_preview", name: "미리보기", scope: "main" },
|
{ key: "main_preview", name: "미리보기", scope: "main" },
|
||||||
{ key: "main_special_rank", name: "특수랭킹", scope: "main" },
|
{ key: "main_special_rank", name: "특수랭킹", scope: "main" },
|
||||||
// list scope (기본/없음 제거, 텍스트 중심)
|
// list scope
|
||||||
|
{ key: "list_default", name: "기본", scope: "list" },
|
||||||
{ key: "list_text", name: "텍스트", scope: "list" },
|
{ key: "list_text", name: "텍스트", scope: "list" },
|
||||||
{ key: "list_preview", name: "미리보기", scope: "list" },
|
{ key: "list_preview", name: "미리보기", scope: "list" },
|
||||||
{ key: "list_special_rank", name: "특수랭킹", scope: "list" },
|
{ key: "list_special_rank", name: "특수랭킹", scope: "list" },
|
||||||
{ key: "list_special_attendance", name: "특수출석", scope: "list" },
|
|
||||||
];
|
];
|
||||||
for (const vt of viewTypes) {
|
for (const vt of viewTypes) {
|
||||||
await prisma.boardViewType.upsert({
|
await prisma.boardViewType.upsert({
|
||||||
@@ -463,23 +297,16 @@ async function createPostsForAllBoards(boards, countPerBoard = 100, admin) {
|
|||||||
const users = await prisma.user.findMany({ select: { userId: true } });
|
const users = await prisma.user.findMany({ select: { userId: true } });
|
||||||
const userIds = users.map((u) => u.userId);
|
const userIds = users.map((u) => u.userId);
|
||||||
for (const board of boards) {
|
for (const board of boards) {
|
||||||
// 회원랭킹 보드는 특수랭킹용이라 게시글을 시드하지 않습니다.
|
|
||||||
if (board.slug === "ranking") continue;
|
|
||||||
const data = [];
|
const data = [];
|
||||||
for (let i = 0; i < countPerBoard; i++) {
|
for (let i = 0; i < countPerBoard; i++) {
|
||||||
const authorId = ["notice", "bug-report"].includes(board.slug)
|
const authorId = ["notice", "bug-report"].includes(board.slug)
|
||||||
? admin.userId
|
? admin.userId
|
||||||
: userIds[randomInt(0, userIds.length - 1)];
|
: 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({
|
data.push({
|
||||||
boardId: board.id,
|
boardId: board.id,
|
||||||
authorId,
|
authorId,
|
||||||
title,
|
title: `${board.name} 샘플 글 ${i + 1}`,
|
||||||
content: `<p>${p1}</p>\n<figure><img src="${img}" alt="seed image" /></figure>\n<p>${p2}</p>\n<p>${p3}</p>`,
|
content: `이 게시판(${board.slug})의 자동 시드 게시물 #${i + 1} 입니다.\n\n테스트용 내용입니다.`,
|
||||||
status: "published",
|
status: "published",
|
||||||
isAnonymous: board.allowAnonymousPost ? Math.random() < 0.3 : false,
|
isAnonymous: board.allowAnonymousPost ? Math.random() < 0.3 : false,
|
||||||
});
|
});
|
||||||
@@ -524,10 +351,10 @@ async function seedPartnerShops() {
|
|||||||
{ region: "강원도", name: "test2", address: "춘천시 중앙로 112", imageUrl: "/sample.jpg", sortOrder: 2 },
|
{ region: "강원도", name: "test2", address: "춘천시 중앙로 112", imageUrl: "/sample.jpg", sortOrder: 2 },
|
||||||
];
|
];
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
await prisma.partnerRequest.upsert({
|
await prisma.partnerShop.upsert({
|
||||||
where: { id: `${it.region}-${it.name}` },
|
where: { name_region: { name: it.name, region: it.region } },
|
||||||
update: { address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true, status: "approved" },
|
update: { address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true },
|
||||||
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 },
|
create: it,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 표시 토글 기본값 보장
|
// 표시 토글 기본값 보장
|
||||||
@@ -555,96 +382,14 @@ async function seedBanners() {
|
|||||||
await prisma.banner.createMany({ data: items });
|
await prisma.banner.createMany({ data: items });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedMainpageVisibleBoards(boards) {
|
|
||||||
const SETTINGS_KEY = "mainpage_settings";
|
|
||||||
const setting = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
|
|
||||||
const current = setting ? JSON.parse(setting.value) : {};
|
|
||||||
const wantSlugs = new Set(["notice", "free", "ranking"]);
|
|
||||||
const visibleBoardIds = boards.filter((b) => wantSlugs.has(b.slug)).map((b) => b.id);
|
|
||||||
const next = { ...current, visibleBoardIds };
|
|
||||||
await prisma.setting.upsert({
|
|
||||||
where: { key: SETTINGS_KEY },
|
|
||||||
update: { value: JSON.stringify(next) },
|
|
||||||
create: { key: SETTINGS_KEY, value: JSON.stringify(next) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
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();
|
await upsertRoles();
|
||||||
const admin = await upsertAdmin();
|
const admin = await upsertAdmin();
|
||||||
const categoryMap = await upsertCategories();
|
const categoryMap = await upsertCategories();
|
||||||
await upsertViewTypes();
|
await upsertViewTypes();
|
||||||
const randomUsers = await createRandomUsers(3);
|
await createRandomUsers(100);
|
||||||
await seedRandomAttendanceForUsers(randomUsers, 10, 20);
|
|
||||||
await removeNonPrimaryBoards();
|
await removeNonPrimaryBoards();
|
||||||
const boards = await upsertBoards(admin, categoryMap);
|
const boards = await upsertBoards(admin, categoryMap);
|
||||||
await seedAdminAttendance(admin);
|
|
||||||
await seedMainpageVisibleBoards(boards);
|
|
||||||
await createPostsForAllBoards(boards, 100, admin);
|
await createPostsForAllBoards(boards, 100, admin);
|
||||||
await seedPartnerShops();
|
await seedPartnerShops();
|
||||||
await seedBanners();
|
await seedBanners();
|
||||||
@@ -656,22 +401,8 @@ async function main() {
|
|||||||
{ name: "test", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
|
{ name: "test", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
|
||||||
{ name: "굿짐", category: "gym", latitude: 37.5600, longitude: 126.9820, 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) {
|
for (const p of partners) {
|
||||||
const categoryRef = p.category ? partnerCategoryMap[p.category] : null;
|
await prisma.partner.upsert({ where: { name: p.name }, update: p, create: p });
|
||||||
const data = { ...p, categoryId: categoryRef ? categoryRef.id : null };
|
|
||||||
await prisma.partner.upsert({ where: { name: p.name }, update: data, create: data });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 279 KiB |
@@ -15,7 +15,7 @@ const navItems = [
|
|||||||
export default function AdminSidebar() {
|
export default function AdminSidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
return (
|
return (
|
||||||
<aside className="w-56 shrink-0 border-r border-neutral-200 bg-white/90 backdrop-blur h-full">
|
<aside className="w-56 shrink-0 border-r border-neutral-200 bg-white/80 backdrop-blur h-full">
|
||||||
<div className="px-4 py-4 border-b border-neutral-200">
|
<div className="px-4 py-4 border-b border-neutral-200">
|
||||||
<Link href="/admin" className="block text-lg font-bold text-neutral-900">관리자</Link>
|
<Link href="/admin" className="block text-lg font-bold text-neutral-900">관리자</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ export default function AdminBoardsPage() {
|
|||||||
const listTypes = useMemo(() => viewTypes.filter((v: any) => v.scope === 'list'), [viewTypes]);
|
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 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 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 categories = useMemo(() => {
|
||||||
const merged = rawCategories.map((c: any) => ({ ...c, ...(dirtyCats[c.id] ?? {}) }));
|
const merged = rawCategories.map((c: any) => ({ ...c, ...(dirtyCats[c.id] ?? {}) }));
|
||||||
return merged.sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
return merged.sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||||
@@ -239,7 +237,7 @@ export default function AdminBoardsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sortOrder = (currentItems?.length ?? 0) + 1;
|
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: textMainTypeId, listViewTypeId: textListTypeId }) });
|
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 mutateBoards();
|
await mutateBoards();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,20 +436,12 @@ 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 }) {
|
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 [edit, setEdit] = useState(b);
|
||||||
// 선택 가능 옵션에서 '기본' 타입은 제외
|
const effectiveMainTypeId = edit.mainPageViewTypeId ?? defaultMainTypeId ?? '';
|
||||||
const selectableMainTypes = (mainTypes ?? []).filter((t: any) => t.id !== defaultMainTypeId);
|
const effectiveListTypeId = edit.listViewTypeId ?? defaultListTypeId ?? '';
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<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 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"><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 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
<select
|
<select
|
||||||
className="h-9 rounded-md border border-neutral-300 px-2 text-sm"
|
className="h-9 rounded-md border border-neutral-300 px-2 text-sm"
|
||||||
@@ -463,11 +453,12 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
|
|||||||
e.currentTarget.value = id ?? '';
|
e.currentTarget.value = id ?? '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const v = { ...edit, mainPageViewTypeId: e.target.value };
|
const v = { ...edit, mainPageViewTypeId: e.target.value || null };
|
||||||
setEdit(v); onDirty(b.id, v);
|
setEdit(v); onDirty(b.id, v);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(selectableMainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
<option value="">(없음)</option>
|
||||||
|
{(mainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||||
<option value="__add__">+ 새 타입 추가…</option>
|
<option value="__add__">+ 새 타입 추가…</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
@@ -482,11 +473,12 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
|
|||||||
e.currentTarget.value = id ?? '';
|
e.currentTarget.value = id ?? '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const v = { ...edit, listViewTypeId: e.target.value };
|
const v = { ...edit, listViewTypeId: e.target.value || null };
|
||||||
setEdit(v); onDirty(b.id, v);
|
setEdit(v); onDirty(b.id, v);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(selectableListTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
<option value="">(없음)</option>
|
||||||
|
{(listTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||||
<option value="__add__">+ 새 타입 추가…</option>
|
<option value="__add__">+ 새 타입 추가…</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
@@ -559,8 +551,8 @@ function CategoryHeaderContent({ g, onDirty }: { g: any; onDirty: (payload: any)
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-10" />
|
<div className="w-10" />
|
||||||
<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" 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); }} />
|
<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); }} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,32 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import AdminSidebar from "@/app/admin/AdminSidebar";
|
import AdminSidebar from "@/app/admin/AdminSidebar";
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Admin | ASSM",
|
title: "Admin | ASSM",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default 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 (
|
return (
|
||||||
<div className="min-h-[calc(100vh-0px)] flex">
|
<div className="min-h-[calc(100vh-0px)] flex">
|
||||||
<AdminSidebar />
|
<AdminSidebar />
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|||||||
|
|
||||||
export default function AdminPartnersPage() {
|
export default function AdminPartnersPage() {
|
||||||
const { data, mutate } = useSWR<{ partners: any[] }>("/api/admin/partners", fetcher);
|
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 partners = data?.partners ?? [];
|
||||||
const categories = catData?.categories ?? [];
|
const [form, setForm] = useState({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
|
||||||
const [form, setForm] = useState({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" });
|
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const editFileInputRef = useRef<HTMLInputElement>(null);
|
const editFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -64,8 +62,8 @@ export default function AdminPartnersPage() {
|
|||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
// 필수값 검증: 이름/카테고리/위도/경도
|
// 필수값 검증: 이름/카테고리/위도/경도
|
||||||
if (!form.name || !form.categoryId || !String(form.latitude).trim() || !String(form.longitude).trim()) {
|
if (!form.name || !form.category || !String(form.latitude).trim() || !String(form.longitude).trim()) {
|
||||||
alert("이름, 카테고리, 위도, 경도를 모두 선택/입력해 주세요.");
|
alert("이름, 카테고리, 위도, 경도를 모두 입력해 주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lat = Number(form.latitude);
|
const lat = Number(form.latitude);
|
||||||
@@ -74,21 +72,12 @@ export default function AdminPartnersPage() {
|
|||||||
alert("위도/경도는 숫자여야 합니다.");
|
alert("위도/경도는 숫자여야 합니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = { ...form, latitude: lat, longitude: lon, categoryId: form.categoryId } as any;
|
const payload = { ...form, latitude: lat, longitude: lon } as any;
|
||||||
const r = await fetch("/api/admin/partners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
const r = await fetch("/api/admin/partners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
setForm({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" });
|
setForm({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
|
||||||
mutate();
|
mutate();
|
||||||
setShowCreateModal(false);
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,12 +107,7 @@ export default function AdminPartnersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ gridColumn: "span 3" }}>
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>카테고리</label>
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>카테고리</label>
|
||||||
<select style={inputStyle} value={form.categoryId} onChange={(e) => setForm({ ...form, categoryId: e.target.value })}>
|
<input style={inputStyle} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} />
|
||||||
<option value="">(없음)</option>
|
|
||||||
{categories.map((c: any) => (
|
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ gridColumn: "span 3" }}>
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
||||||
@@ -194,12 +178,7 @@ export default function AdminPartnersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ gridColumn: "span 3" }}>
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>카테고리</label>
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>카테고리</label>
|
||||||
<select style={inputStyle} value={editDraft?.categoryId ?? ""} onChange={(e) => setEditDraft({ ...editDraft, categoryId: e.target.value })}>
|
<input style={inputStyle} value={editDraft?.category ?? ""} onChange={(e) => setEditDraft({ ...editDraft, category: e.target.value })} />
|
||||||
<option value="">(없음)</option>
|
|
||||||
{categories.map((c: any) => (
|
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ gridColumn: "span 3" }}>
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
||||||
@@ -237,23 +216,7 @@ export default function AdminPartnersPage() {
|
|||||||
)}
|
)}
|
||||||
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
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(); }}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
저장
|
저장
|
||||||
@@ -287,14 +250,14 @@ export default function AdminPartnersPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<div><strong>{p.name}</strong> {p.categoryRef ? <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>[{p.categoryRef.name}]</span> : null}</div>
|
<div><strong>{p.name}</strong> <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>{p.category}</span></div>
|
||||||
<div style={{ fontSize: 12, opacity: .7 }}>{p.address || "(주소 없음)"}</div>
|
<div style={{ fontSize: 12, opacity: .7 }}>{p.address || "(주소 없음)"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, opacity: .7 }}>위도 {p.latitude}</div>
|
<div style={{ fontSize: 12, opacity: .7 }}>위도 {p.latitude}</div>
|
||||||
<div style={{ fontSize: 12, opacity: .7 }}>경도 {p.longitude}</div>
|
<div style={{ fontSize: 12, opacity: .7 }}>경도 {p.longitude}</div>
|
||||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||||
<button
|
<button
|
||||||
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); }}
|
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); }}
|
||||||
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
||||||
>
|
>
|
||||||
수정
|
수정
|
||||||
@@ -325,49 +288,8 @@ export default function AdminPartnersPage() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div>
|
|
||||||
<hr style={{ margin: "24px 0" }} />
|
|
||||||
<h2 className="text-lg font-bold mb-2">카테고리 관리</h2>
|
|
||||||
<CategoryManager categories={categories} onChanged={mutateCategories} />
|
|
||||||
</div>
|
|
||||||
</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,7 +47,6 @@ export default function AdminUsersPage() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-[#f6f4f4] border-b border-[#e6e6e6]">
|
<thead className="bg-[#f6f4f4] border-b border-[#e6e6e6]">
|
||||||
<tr>
|
<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>
|
<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>
|
||||||
@@ -91,7 +90,6 @@ function Row({ u, onChanged }: { u: any; onChanged: () => void }) {
|
|||||||
const allRoles = ["admin", "editor", "user"] as const;
|
const allRoles = ["admin", "editor", "user"] as const;
|
||||||
return (
|
return (
|
||||||
<tr className="hover:bg-neutral-50">
|
<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.nickname}</td>
|
||||||
<td className="px-4 py-2 text-left">{u.name}</td>
|
<td className="px-4 py-2 text-left">{u.name}</td>
|
||||||
<td className="px-4 py-2 text-left">{u.phone}</td>
|
<td className="px-4 py-2 text-left">{u.phone}</td>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -25,7 +24,7 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const parsed = createSchema.safeParse(body);
|
const parsed = createSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
const banner = await prisma.banner.create({ data: parsed.data as Prisma.BannerCreateInput });
|
const banner = await prisma.banner.create({ data: parsed.data });
|
||||||
return NextResponse.json({ banner }, { status: 201 });
|
return NextResponse.json({ banner }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -55,13 +54,7 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
sortOrder = (max._max.sortOrder ?? 0) + 1;
|
sortOrder = (max._max.sortOrder ?? 0) + 1;
|
||||||
}
|
}
|
||||||
const { categoryId, sortOrder: _ignored, ...rest } = data;
|
const created = await prisma.board.create({ data: { ...data, sortOrder } });
|
||||||
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 });
|
return NextResponse.json({ board: created }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const categories = await prisma.boardCategory.findMany({
|
const categories = await prisma.boardCategory.findMany({
|
||||||
@@ -21,7 +20,7 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const parsed = createSchema.safeParse(body);
|
const parsed = createSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
const category = await prisma.boardCategory.create({ data: parsed.data as Prisma.BoardCategoryCreateInput });
|
const category = await prisma.boardCategory.create({ data: parsed.data });
|
||||||
return NextResponse.json({ category }, { status: 201 });
|
return NextResponse.json({ category }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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"]) {
|
for (const k of ["region", "name", "address", "imageUrl", "active", "sortOrder"]) {
|
||||||
if (k in body) data[k] = body[k];
|
if (k in body) data[k] = body[k];
|
||||||
}
|
}
|
||||||
const item = await prisma.partnerRequest.update({ where: { id }, data });
|
const item = await prisma.partnerShop.update({ where: { id }, data });
|
||||||
return NextResponse.json({ item });
|
return NextResponse.json({ item });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
await prisma.partnerRequest.delete({ where: { id } });
|
await prisma.partnerShop.delete({ where: { id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const rows = await prisma.partnerRequest.findMany({
|
const items = await prisma.partnerShop.findMany({ orderBy: [{ active: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }] });
|
||||||
where: { status: "approved" },
|
return NextResponse.json({ items });
|
||||||
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({
|
const createSchema = z.object({
|
||||||
@@ -25,8 +20,7 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const parsed = createSchema.safeParse(body);
|
const parsed = createSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
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 });
|
return NextResponse.json({ item }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,12 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
|
|||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const data: any = {};
|
const data: any = {};
|
||||||
for (const k of ["name", "category", "latitude", "longitude", "address", "imageUrl", "sortOrder", "categoryId"]) {
|
for (const k of ["name", "category", "latitude", "longitude", "address", "imageUrl", "sortOrder"]) {
|
||||||
if (k in body) data[k] = body[k];
|
if (k in body) data[k] = body[k];
|
||||||
}
|
}
|
||||||
if (typeof data.latitude !== "undefined") data.latitude = Number(data.latitude);
|
if (typeof data.latitude !== "undefined") data.latitude = Number(data.latitude);
|
||||||
if (typeof data.longitude !== "undefined") data.longitude = Number(data.longitude);
|
if (typeof data.longitude !== "undefined") data.longitude = Number(data.longitude);
|
||||||
if (typeof data.sortOrder !== "undefined") data.sortOrder = Number(data.sortOrder);
|
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 {
|
try {
|
||||||
const partner = await prisma.partner.update({ where: { id }, data });
|
const partner = await prisma.partner.update({ where: { id }, data });
|
||||||
return NextResponse.json({ partner });
|
return NextResponse.json({ partner });
|
||||||
|
|||||||
@@ -4,19 +4,19 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
// 카테고리 조인 포함
|
// 정렬용 컬럼(sortOrder)이 있는 경우 우선 사용
|
||||||
const partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], include: { categoryRef: true } });
|
const partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }] });
|
||||||
return NextResponse.json({ partners });
|
return NextResponse.json({ partners });
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// 컬럼이 아직 DB에 없으면 createdAt 기준으로 폴백
|
// 컬럼이 아직 DB에 없으면 createdAt 기준으로 폴백
|
||||||
const partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" }, include: { categoryRef: true } });
|
const partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" } });
|
||||||
return NextResponse.json({ partners });
|
return NextResponse.json({ partners });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
category: z.string().min(1).optional(),
|
category: z.string().min(1),
|
||||||
latitude: z.coerce.number(),
|
latitude: z.coerce.number(),
|
||||||
longitude: z.coerce.number(),
|
longitude: z.coerce.number(),
|
||||||
address: z.string().min(1).optional(),
|
address: z.string().min(1).optional(),
|
||||||
@@ -27,28 +27,14 @@ const createSchema = z.object({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
sortOrder: z.coerce.number().int().optional(),
|
sortOrder: z.coerce.number().int().optional(),
|
||||||
categoryId: z.string().min(1),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const parsed = createSchema.safeParse(body);
|
const parsed = createSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
try {
|
const partner = await prisma.partner.create({ data: parsed.data as any });
|
||||||
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 });
|
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,7 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const items = await prisma.boardViewType.findMany({ orderBy: [{ scope: 'asc' }, { name: 'asc' }] });
|
const items = await prisma.boardViewType.findMany({ orderBy: [{ scope: 'asc' }, { name: 'asc' }] });
|
||||||
@@ -20,7 +19,7 @@ export async function POST(req: Request) {
|
|||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
const exists = await prisma.boardViewType.findFirst({ where: { key: parsed.data.key } });
|
const exists = await prisma.boardViewType.findFirst({ where: { key: parsed.data.key } });
|
||||||
if (exists) return NextResponse.json({ error: 'duplicate_key' }, { status: 409 });
|
if (exists) return NextResponse.json({ error: 'duplicate_key' }, { status: 409 });
|
||||||
const created = await prisma.boardViewType.create({ data: parsed.data as Prisma.BoardViewTypeCreateInput });
|
const created = await prisma.boardViewType.create({ data: parsed.data });
|
||||||
return NextResponse.json({ item: created }, { status: 201 });
|
return NextResponse.json({ item: created }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
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,42 +1,16 @@
|
|||||||
export const dynamic = "force-dynamic";
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { getUserIdFromRequest } from "@/lib/auth";
|
import { getUserIdFromRequest } from "@/lib/auth";
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const userId = getUserIdFromRequest(req);
|
const userId = getUserIdFromRequest(req);
|
||||||
if (!userId) return NextResponse.json({ today: null, count: 0, days: [] });
|
if (!userId) return NextResponse.json({ today: null, count: 0 });
|
||||||
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 start = new Date(); start.setHours(0,0,0,0);
|
||||||
const end = new Date(); end.setHours(23,59,59,999);
|
const end = new Date(); end.setHours(23,59,59,999);
|
||||||
const today = await prisma.attendance.findFirst({
|
const today = await prisma.pointTransaction.findFirst({
|
||||||
where: { userId, date: { gte: start, lte: end } },
|
where: { userId, reason: "attendance", createdAt: { gte: start, lte: end } },
|
||||||
});
|
});
|
||||||
const count = await prisma.attendance.count({ where: { userId } });
|
const count = await prisma.pointTransaction.count({ where: { userId, reason: "attendance" } });
|
||||||
// 월별 출석 일자 목록
|
|
||||||
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 });
|
return NextResponse.json({ today: !!today, count });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,12 +19,9 @@ export async function POST(req: Request) {
|
|||||||
if (!userId) return NextResponse.json({ error: "login required" }, { status: 401 });
|
if (!userId) return NextResponse.json({ error: "login required" }, { status: 401 });
|
||||||
const start = new Date(); start.setHours(0,0,0,0);
|
const start = new Date(); start.setHours(0,0,0,0);
|
||||||
const end = new Date(); end.setHours(23,59,59,999);
|
const end = new Date(); end.setHours(23,59,59,999);
|
||||||
const exists = await prisma.attendance.findFirst({ where: { userId, date: { gte: start, lte: end } } });
|
const exists = await prisma.pointTransaction.findFirst({ where: { userId, reason: "attendance", createdAt: { gte: start, lte: end } } });
|
||||||
if (exists) return NextResponse.json({ ok: true, duplicated: true });
|
if (exists) return NextResponse.json({ ok: true, duplicated: true });
|
||||||
// normalize to UTC midnight
|
await prisma.pointTransaction.create({ data: { userId, amount: 10, reason: "attendance" } });
|
||||||
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 });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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,18 +12,11 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const parsed = loginSchema.safeParse(body);
|
const parsed = loginSchema.safeParse(body);
|
||||||
if (!parsed.success)
|
if (!parsed.success)
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
|
const { nickname, password } = parsed.data;
|
||||||
{ status: 401 }
|
const user = await prisma.user.findUnique({ where: { nickname } });
|
||||||
);
|
|
||||||
const { id, password } = parsed.data;
|
|
||||||
// DB에서는 로그인 아이디를 nickname 컬럼으로 보관
|
|
||||||
const user = await prisma.user.findUnique({ where: { nickname: id } });
|
|
||||||
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
|
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 });
|
||||||
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return NextResponse.json({ user: { userId: user.userId, nickname: user.nickname } });
|
return NextResponse.json({ user: { userId: user.userId, nickname: user.nickname } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,33 +5,13 @@ import { getUserIdFromRequest } from "@/lib/auth";
|
|||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const userId = getUserIdFromRequest(req);
|
const userId = getUserIdFromRequest(req);
|
||||||
if (!userId) return NextResponse.json({ permissions: [] });
|
if (!userId) return NextResponse.json({ permissions: [] });
|
||||||
|
const roles = await prisma.userRole.findMany({ where: { userId }, select: { roleId: true } });
|
||||||
const user = await prisma.user.findUnique({
|
if (roles.length === 0) return NextResponse.json({ permissions: [] });
|
||||||
where: { userId },
|
const roleIds = roles.map((r) => r.roleId);
|
||||||
select: {
|
const permissions = await prisma.rolePermission.findMany({
|
||||||
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 },
|
where: { roleId: { in: roleIds }, allowed: true },
|
||||||
select: { resource: true, action: 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 });
|
return NextResponse.json({ permissions });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,41 +8,17 @@ export async function POST(req: Request) {
|
|||||||
const parsed = registerSchema.safeParse(body);
|
const parsed = registerSchema.safeParse(body);
|
||||||
if (!parsed.success)
|
if (!parsed.success)
|
||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
const { nickname, name, password, profileImage } = parsed.data as {
|
const { nickname, name, phone, birth, password } = parsed.data;
|
||||||
nickname: string;
|
const exists = await prisma.user.findFirst({ where: { OR: [{ nickname }, { phone }] } });
|
||||||
name: string;
|
if (exists) return NextResponse.json({ error: "이미 존재하는 사용자" }, { status: 409 });
|
||||||
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({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
userId: nickname,
|
|
||||||
nickname,
|
nickname,
|
||||||
name,
|
name,
|
||||||
|
phone,
|
||||||
|
birth: new Date(birth),
|
||||||
passwordHash: hashPassword(password),
|
passwordHash: hashPassword(password),
|
||||||
agreementTermsAt: new Date(),
|
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 },
|
select: { userId: true, nickname: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,59 +29,16 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const parsed = loginSchema.safeParse(body);
|
const parsed = loginSchema.safeParse(body);
|
||||||
if (!parsed.success)
|
if (!parsed.success)
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
{ error: "아이디 또는 비밀번호가 일치하지 않습니다" },
|
const { nickname, password } = parsed.data;
|
||||||
{ status: 401 }
|
const user = await prisma.user.findUnique({ where: { nickname } });
|
||||||
);
|
|
||||||
const { id, password } = parsed.data;
|
|
||||||
// DB에서는 로그인 아이디를 nickname 컬럼으로 보관
|
|
||||||
const user = await prisma.user.findUnique({ where: { nickname: id } });
|
|
||||||
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
|
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "아이디 또는 비밀번호가 올바르지 않습니다" }, { status: 401 });
|
||||||
{ 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 } });
|
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(
|
res.headers.append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
`uid=${encodeURIComponent(user.userId)}; Path=/; HttpOnly; SameSite=Lax${secureAttr}`
|
`uid=${encodeURIComponent(user.userId)}; Path=/; HttpOnly; SameSite=Lax`
|
||||||
);
|
|
||||||
res.headers.append(
|
|
||||||
"Set-Cookie",
|
|
||||||
`isAdmin=${isAdmin ? "1" : "0"}; Path=/; HttpOnly; SameSite=Lax${secureAttr}`
|
|
||||||
);
|
);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -89,7 +46,6 @@ export async function POST(req: Request) {
|
|||||||
export async function DELETE() {
|
export async function DELETE() {
|
||||||
const res = NextResponse.json({ ok: true });
|
const res = NextResponse.json({ ok: true });
|
||||||
res.headers.append("Set-Cookie", `uid=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`);
|
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;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import prisma from "@/lib/prisma";
|
|||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const category = searchParams.get("category"); // slug or id
|
const category = searchParams.get("category"); // slug or id
|
||||||
const where: any = { status: "active" };
|
const where: any = {};
|
||||||
if (category) {
|
if (category) {
|
||||||
if (category.length === 25 || category.length === 24) {
|
if (category.length === 25 || category.length === 24) {
|
||||||
where.categoryId = category;
|
where.categoryId = category;
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import { NextResponse } from "next/server";
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { hashPassword } from "@/lib/password";
|
import { hashPassword } from "@/lib/password";
|
||||||
import { getUserIdOrAdmin } from "@/lib/auth";
|
|
||||||
|
|
||||||
const createCommentSchema = z.object({
|
const createCommentSchema = z.object({
|
||||||
postId: z.string().min(1),
|
postId: z.string().min(1),
|
||||||
parentId: z.string().nullable().optional(), // 부모 댓글 ID (대댓글). 최상위 댓글은 null 허용
|
|
||||||
authorId: z.string().optional(),
|
authorId: z.string().optional(),
|
||||||
content: z.string().min(1),
|
content: z.string().min(1),
|
||||||
isAnonymous: z.boolean().optional(),
|
isAnonymous: z.boolean().optional(),
|
||||||
@@ -20,51 +18,17 @@ export async function POST(req: Request) {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
}
|
}
|
||||||
const { postId, parentId, authorId, content, isAnonymous, isSecret, secretPassword } = parsed.data;
|
const { postId, 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 secretPasswordHash = secretPassword ? hashPassword(secretPassword) : null;
|
||||||
const comment = await prisma.comment.create({
|
const comment = await prisma.comment.create({
|
||||||
data: {
|
data: {
|
||||||
postId,
|
postId,
|
||||||
parentId: parentId ?? null,
|
authorId: authorId ?? null,
|
||||||
depth,
|
|
||||||
authorId: effectiveAuthorId ?? null,
|
|
||||||
content,
|
content,
|
||||||
isAnonymous: !!isAnonymous,
|
isAnonymous: !!isAnonymous,
|
||||||
isSecret: !!isSecret,
|
isSecret: !!isSecret,
|
||||||
secretPasswordHash,
|
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 });
|
return NextResponse.json({ comment }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -31,7 +30,7 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const parsed = createSchema.safeParse(body);
|
const parsed = createSchema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
const data = await prisma.coupon.create({ data: parsed.data as Prisma.CouponCreateInput });
|
const data = await prisma.coupon.create({ data: parsed.data });
|
||||||
return NextResponse.json({ coupon: data }, { status: 201 });
|
return NextResponse.json({ coupon: data }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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,7 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { z } from "zod";
|
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) });
|
const schema = z.object({ name: z.string().min(1), contact: z.string().min(1), category: z.string().optional(), message: z.string().min(1) });
|
||||||
|
|
||||||
@@ -9,7 +8,7 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const parsed = schema.safeParse(body);
|
const parsed = schema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
const created = await prisma.partnerInquiry.create({ data: parsed.data as Prisma.PartnerInquiryCreateInput });
|
const created = await prisma.partnerInquiry.create({ data: parsed.data });
|
||||||
return NextResponse.json({ inquiry: created }, { status: 201 });
|
return NextResponse.json({ inquiry: created }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { z } from "zod";
|
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() });
|
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() });
|
||||||
|
|
||||||
@@ -9,7 +8,7 @@ export async function POST(req: Request) {
|
|||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const parsed = schema.safeParse(body);
|
const parsed = schema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
const created = await prisma.partnerRequest.create({ data: parsed.data as Prisma.PartnerRequestCreateInput });
|
const created = await prisma.partnerRequest.create({ data: parsed.data });
|
||||||
return NextResponse.json({ request: created }, { status: 201 });
|
return NextResponse.json({ request: created }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,10 @@ import { NextResponse } from "next/server";
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
// 통합: 승인된 파트너 요청을 메인 노출용으로 사용
|
const items = await prisma.partnerShop.findMany({
|
||||||
const rows = await prisma.partnerRequest.findMany({
|
where: { active: true },
|
||||||
where: { status: "approved", active: true },
|
|
||||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
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 });
|
return NextResponse.json({ items });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
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) {
|
export async function GET(req: Request) {
|
||||||
const { searchParams } = new URL(req.url);
|
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 category = searchParams.get("category") || undefined;
|
||||||
const categoryId = searchParams.get("categoryId") || undefined;
|
const radius = Number(searchParams.get("radius")) || 10; // km
|
||||||
let where: any = {};
|
const where = category ? { category } : {};
|
||||||
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 partners = await prisma.partner.findMany({ where, orderBy: { createdAt: "desc" } });
|
||||||
return NextResponse.json({ partners });
|
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +1,28 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { getUserIdFromRequest } from "@/lib/auth";
|
|
||||||
|
|
||||||
export async function GET(req: Request, context: { params: Promise<{ id: string }> }) {
|
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const requesterId = getUserIdFromRequest(req);
|
const comments = await prisma.comment.findMany({
|
||||||
const post = await prisma.post.findUnique({
|
where: { postId: id },
|
||||||
where: { id },
|
|
||||||
select: { authorId: true },
|
|
||||||
});
|
|
||||||
const postAuthorId = post?.authorId ?? null;
|
|
||||||
|
|
||||||
// 최상위 댓글만 가져오기
|
|
||||||
const topComments = await prisma.comment.findMany({
|
|
||||||
where: {
|
|
||||||
postId: id,
|
|
||||||
parentId: null, // 최상위 댓글만
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
include: {
|
select: {
|
||||||
author: { select: { userId: true, nickname: true, profileImage: true } },
|
id: true,
|
||||||
replies: {
|
content: true,
|
||||||
include: {
|
isAnonymous: true,
|
||||||
author: { select: { userId: true, nickname: true, profileImage: true } },
|
isSecret: true,
|
||||||
replies: {
|
secretPasswordHash: true,
|
||||||
include: {
|
createdAt: true,
|
||||||
author: { select: { userId: true, nickname: true, profileImage: true } },
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const presented = comments.map((c) => ({
|
||||||
// 재귀적으로 댓글 구조 변환
|
id: c.id,
|
||||||
const transformComment = (comment: any): any => {
|
content: c.isSecret ? "비밀댓글입니다." : c.content,
|
||||||
const commentAuthorId: string | null = comment.author?.userId ?? null;
|
isAnonymous: c.isAnonymous,
|
||||||
const canViewSecret =
|
isSecret: c.isSecret,
|
||||||
!comment.isSecret ||
|
anonId: c.isAnonymous ? c.id.slice(-6) : undefined,
|
||||||
(requesterId != null &&
|
createdAt: c.createdAt,
|
||||||
(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 });
|
return NextResponse.json({ comments: presented });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +1,18 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { getUserIdFromRequest } from "@/lib/auth";
|
import { z } from "zod";
|
||||||
import crypto from "crypto";
|
|
||||||
|
const schema = z.object({ userId: z.string().optional(), clientHash: z.string().optional() }).refine(
|
||||||
|
(d) => !!d.userId || !!d.clientHash,
|
||||||
|
{ message: "Provide userId or clientHash" }
|
||||||
|
);
|
||||||
|
|
||||||
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
|
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
// 1) 사용자 식별 시도: 쿠키/헤더에서 userId 우선
|
const body = await req.json().catch(() => ({}));
|
||||||
let userId = getUserIdFromRequest(req);
|
const parsed = schema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
// 2) 바디에서 clientHash 수용(클라이언트가 보낼 수 있음)
|
const { userId, clientHash } = parsed.data;
|
||||||
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({
|
const existing = await prisma.reaction.findFirst({
|
||||||
where: { postId: id, type: "RECOMMEND", userId: userId ?? null, clientHash: clientHash ?? null },
|
where: { postId: id, type: "RECOMMEND", userId: userId ?? null, clientHash: clientHash ?? null },
|
||||||
@@ -65,19 +27,6 @@ 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 });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ export async function GET(_: Request, context: { params: Promise<{ id: string }>
|
|||||||
const post = await prisma.post.findUnique({
|
const post = await prisma.post.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
author: { select: { userId: true, nickname: true } },
|
|
||||||
board: { select: { id: true, name: true, slug: 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 });
|
if (!post) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|||||||
@@ -5,81 +5,11 @@ import { getUserIdFromRequest } from "@/lib/auth";
|
|||||||
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
|
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const userId = getUserIdFromRequest(req);
|
const userId = getUserIdFromRequest(req);
|
||||||
// x-forwarded-for는 다중 IP가 올 수 있음 -> 첫 번째(실제 클라이언트)만 사용
|
const ip = req.headers.get("x-forwarded-for") || undefined;
|
||||||
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;
|
const userAgent = req.headers.get("user-agent") || undefined;
|
||||||
|
await prisma.postViewLog.create({ data: { postId: id, userId: userId ?? null, ip, userAgent } });
|
||||||
// 중복 방지: 로그인 사용자는 userId로, 비로그인은 (ip + userAgent) 조합으로 1회만 카운트
|
await prisma.postStat.upsert({ where: { postId: id }, update: { views: { increment: 1 } }, create: { postId: id, views: 1 } });
|
||||||
const orConditions: any[] = [];
|
return NextResponse.json({ ok: true });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
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,7 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getUserIdOrAdmin } from "@/lib/auth";
|
|
||||||
|
|
||||||
const createPostSchema = z.object({
|
const createPostSchema = z.object({
|
||||||
boardId: z.string().min(1),
|
boardId: z.string().min(1),
|
||||||
@@ -18,8 +17,6 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
}
|
}
|
||||||
const { boardId, authorId, title, content, isAnonymous } = parsed.data;
|
const { boardId, authorId, title, content, isAnonymous } = parsed.data;
|
||||||
// 개발 편의를 위해, 인증 정보가 없으면 admin 사용자로 대체
|
|
||||||
const effectiveAuthorId = authorId ?? (await getUserIdOrAdmin(req));
|
|
||||||
const board = await prisma.board.findUnique({ where: { id: boardId } });
|
const board = await prisma.board.findUnique({ where: { id: boardId } });
|
||||||
// 사진형 보드 필수 이미지 검증: content 내 이미지 링크 최소 1개
|
// 사진형 보드 필수 이미지 검증: content 내 이미지 링크 최소 1개
|
||||||
const isImageOnly = (board?.requiredFields as any)?.imageOnly;
|
const isImageOnly = (board?.requiredFields as any)?.imageOnly;
|
||||||
@@ -33,7 +30,7 @@ export async function POST(req: Request) {
|
|||||||
const post = await prisma.post.create({
|
const post = await prisma.post.create({
|
||||||
data: {
|
data: {
|
||||||
boardId,
|
boardId,
|
||||||
authorId: effectiveAuthorId ?? null,
|
authorId: authorId ?? null,
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
isAnonymous: !!isAnonymous,
|
isAnonymous: !!isAnonymous,
|
||||||
@@ -48,10 +45,9 @@ const listQuerySchema = z.object({
|
|||||||
pageSize: z.coerce.number().min(1).max(100).default(10),
|
pageSize: z.coerce.number().min(1).max(100).default(10),
|
||||||
boardId: z.string().optional(),
|
boardId: z.string().optional(),
|
||||||
q: z.string().optional(),
|
q: z.string().optional(),
|
||||||
sort: z.enum(["recent", "popular", "views", "likes", "comments"]).default("recent").optional(),
|
sort: z.enum(["recent", "popular"]).default("recent").optional(),
|
||||||
tag: z.string().optional(), // Tag.slug
|
tag: z.string().optional(), // Tag.slug
|
||||||
author: z.string().optional(), // User.nickname contains
|
author: z.string().optional(), // User.nickname contains
|
||||||
authorId: z.string().optional(), // User.userId exact match
|
|
||||||
start: z.coerce.date().optional(), // createdAt >= start
|
start: z.coerce.date().optional(), // createdAt >= start
|
||||||
end: z.coerce.date().optional(), // createdAt <= end
|
end: z.coerce.date().optional(), // createdAt <= end
|
||||||
});
|
});
|
||||||
@@ -62,7 +58,7 @@ export async function GET(req: Request) {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
}
|
}
|
||||||
const { page, pageSize, boardId, q, sort = "recent", tag, author, authorId, start, end } = parsed.data;
|
const { page, pageSize, boardId, q, sort = "recent", tag, author, start, end } = parsed.data;
|
||||||
const where = {
|
const where = {
|
||||||
NOT: { status: "deleted" as const },
|
NOT: { status: "deleted" as const },
|
||||||
...(boardId ? { boardId } : {}),
|
...(boardId ? { boardId } : {}),
|
||||||
@@ -79,11 +75,7 @@ export async function GET(req: Request) {
|
|||||||
postTags: { some: { tag: { slug: tag } } },
|
postTags: { some: { tag: { slug: tag } } },
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(authorId
|
...(author
|
||||||
? {
|
|
||||||
authorId,
|
|
||||||
}
|
|
||||||
: author
|
|
||||||
? {
|
? {
|
||||||
author: { nickname: { contains: author } },
|
author: { nickname: { contains: author } },
|
||||||
}
|
}
|
||||||
@@ -103,12 +95,8 @@ export async function GET(req: Request) {
|
|||||||
prisma.post.findMany({
|
prisma.post.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy:
|
orderBy:
|
||||||
sort === "popular" || sort === "likes"
|
sort === "popular"
|
||||||
? [{ isPinned: "desc" }, { stat: { recommendCount: "desc" } }, { createdAt: "desc" }]
|
? [{ 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" }],
|
: [{ isPinned: "desc" }, { createdAt: "desc" }],
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
@@ -117,10 +105,9 @@ export async function GET(req: Request) {
|
|||||||
title: true,
|
title: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
boardId: true,
|
boardId: true,
|
||||||
board: { select: { id: true, name: true, slug: true } },
|
|
||||||
isPinned: true,
|
isPinned: true,
|
||||||
status: true,
|
status: true,
|
||||||
author: { select: { userId: true, nickname: true } },
|
author: { select: { nickname: true } },
|
||||||
stat: { select: { recommendCount: true, views: true, commentsCount: true } },
|
stat: { select: { recommendCount: true, views: true, commentsCount: true } },
|
||||||
postTags: { select: { tag: { select: { name: true, slug: true } } } },
|
postTags: { select: { tag: { select: { name: true, slug: true } } } },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,153 +1,83 @@
|
|||||||
import { PostList } from "@/app/components/PostList";
|
import { PostList } from "@/app/components/PostList";
|
||||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||||
import Link from "next/link";
|
|
||||||
import { BoardToolbar } from "@/app/components/BoardToolbar";
|
import { BoardToolbar } from "@/app/components/BoardToolbar";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import prisma from "@/lib/prisma";
|
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가 될 수 있어 안전 언랩 처리합니다.
|
// Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다.
|
||||||
export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) {
|
export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) {
|
||||||
const p = params?.then ? await params : params;
|
const p = params?.then ? await params : params;
|
||||||
const sp = searchParams?.then ? await searchParams : searchParams;
|
const sp = searchParams?.then ? await searchParams : searchParams;
|
||||||
const idOrSlug = p.id as string;
|
const idOrSlug = p.id as string;
|
||||||
const sort = (sp?.sort as "recent" | "popular" | "views" | "likes" | "comments" | undefined) ?? "recent";
|
const sort = (sp?.sort as "recent" | "popular" | undefined) ?? "recent";
|
||||||
// 보드 slug 조회 (새 글 페이지 프리셋 전달)
|
// 보드 slug 조회 (새 글 페이지 프리셋 전달)
|
||||||
const h = await headers();
|
const h = await headers();
|
||||||
const host = h.get("host") ?? "localhost:3000";
|
const host = h.get("host") ?? "localhost:3000";
|
||||||
const proto = h.get("x-forwarded-proto") ?? "http";
|
const proto = h.get("x-forwarded-proto") ?? "http";
|
||||||
const base = process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`;
|
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 res = await fetch(new URL("/api/boards", base).toString(), { cache: "no-store" });
|
||||||
const { boards } = await res.json();
|
const { boards } = await res.json();
|
||||||
const board = (boards || []).find((b: any) => b.slug === idOrSlug || b.id === idOrSlug);
|
const board = (boards || []).find((b: any) => b.slug === idOrSlug || b.id === idOrSlug);
|
||||||
const id = board?.id as string;
|
const id = board?.id as string;
|
||||||
const siblingBoards = (boards || []).filter((b: any) => b.category?.id && b.category.id === board?.category?.id);
|
const siblingBoards = (boards || []).filter((b: any) => b.category?.id && b.category.id === board?.category?.id);
|
||||||
const categoryName = board?.category?.name ?? "";
|
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({
|
const boardView = await prisma.board.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: { listViewType: { select: { key: true } } },
|
select: { listViewType: { select: { key: true } } },
|
||||||
});
|
});
|
||||||
const isSpecialRanking = boardView?.listViewType?.key === "list_special_rank";
|
const isSpecialRanking = boardView?.listViewType?.key === "list_special_rank";
|
||||||
const isAttendance = boardView?.listViewType?.key === "list_special_attendance";
|
|
||||||
|
|
||||||
let rankingItems: { userId: string; nickname: string; points: number; profileImage: string | null; grade: number }[] = [];
|
let rankingItems: { userId: string; nickname: string; points: number }[] = [];
|
||||||
if (isSpecialRanking) {
|
if (isSpecialRanking) {
|
||||||
const topUsers = await prisma.user.findMany({
|
const topUsers = await prisma.user.findMany({
|
||||||
select: { userId: true, nickname: true, points: true, profileImage: true, grade: true },
|
select: { userId: true, nickname: true, points: true },
|
||||||
where: { status: "active" },
|
where: { status: "active" },
|
||||||
orderBy: { points: "desc" },
|
orderBy: { points: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
});
|
});
|
||||||
rankingItems = topUsers.map((u) => ({ userId: u.userId, nickname: u.nickname, points: u.points, profileImage: u.profileImage, grade: u.grade }));
|
rankingItems = topUsers.map((u) => ({ userId: u.userId, nickname: u.nickname, points: u.points }));
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 상단 배너 (서브카테고리 표시) */}
|
{/* 상단 배너 (서브카테고리 표시) */}
|
||||||
{showBanner ? (
|
|
||||||
<section>
|
<section>
|
||||||
<HeroBanner
|
<HeroBanner
|
||||||
subItems={siblingBoards.map((b: any) => ({ id: b.id, name: b.name, href: `/boards/${b.slug}` }))}
|
subItems={siblingBoards.map((b: any) => ({ id: b.id, name: b.name, href: `/boards/${b.slug}` }))}
|
||||||
activeSubId={id}
|
activeSubId={id}
|
||||||
/>
|
/>
|
||||||
</section>
|
</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 className="px-[0px] md:px-[30px] ">
|
<section>
|
||||||
{!isSpecialRanking && !isAttendance && <BoardToolbar boardId={board?.slug} />}
|
{!isSpecialRanking && <BoardToolbar boardId={board?.slug} />}
|
||||||
<div className="p-0">
|
<div className="p-0">
|
||||||
{isSpecialRanking ? (
|
{isSpecialRanking ? (
|
||||||
<div className="w-full">
|
<div className="rounded-xl border border-neutral-200 overflow-hidden">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[32px]">
|
<div className="px-4 py-3 border-b border-neutral-200 flex items-center justify-between bg-[#f6f4f4]">
|
||||||
{rankingItems.map((i, idx) => {
|
<h2 className="text-sm text-neutral-700">포인트 랭킹</h2>
|
||||||
const rank = idx + 1;
|
|
||||||
return (
|
|
||||||
<div key={i.userId} className="border-t border-[#d5d5d5]">
|
|
||||||
<div className="flex gap-[16px] items-center p-[16px]">
|
|
||||||
<div className="flex items-center gap-[8px] shrink-0">
|
|
||||||
{(rank === 1 || rank === 2 || rank === 3) && (
|
|
||||||
<div className="relative w-[20px] h-[20px] shrink-0">
|
|
||||||
{rank === 1 && <RankIcon1st />}
|
|
||||||
{rank === 2 && <RankIcon2nd />}
|
|
||||||
{rank === 3 && <RankIcon3rd />}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<ol className="divide-y divide-neutral-200">
|
||||||
<div className="bg-white border border-[#d5d5d5] px-[16px] py-[5px] rounded-[12px] text-[14px] text-[#5c5c5c] shrink-0">
|
{rankingItems.map((i, idx) => (
|
||||||
{rank}위
|
<li key={i.userId} className="px-4 py-3 flex items-center justify-between">
|
||||||
</div>
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div className="flex items-center gap-[10px] shrink-0 pl-0 pr-[15px] py-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>
|
||||||
<UserAvatar src={i.profileImage} alt={i.nickname || "프로필"} width={36} height={36} className="rounded-full" />
|
<span className="truncate text-neutral-900 font-medium">{i.nickname || "회원"}</span>
|
||||||
<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>
|
</div>
|
||||||
|
<div className="shrink-0 text-sm text-neutral-700">{i.points}점</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
{rankingItems.length === 0 && (
|
{rankingItems.length === 0 && (
|
||||||
<div className="px-4 py-10 text-center text-neutral-500">랭킹 데이터가 없습니다.</div>
|
<li className="px-4 py-10 text-center text-neutral-500">랭킹 데이터가 없습니다.</li>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ol>
|
||||||
) : isAttendance ? (
|
|
||||||
<div className="w-full py-4">
|
|
||||||
<AttendanceCalendar isLoggedIn={isLoggedIn} />
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<PostList
|
<PostList
|
||||||
boardId={id}
|
boardId={id}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
variant="board"
|
variant="board"
|
||||||
titleHoverOrange
|
|
||||||
newPostHref={`/posts/new?boardId=${id}${board?.slug ? `&boardSlug=${board.slug}` : ""}`}
|
newPostHref={`/posts/new?boardId=${id}${board?.slug ? `&boardSlug=${board.slug}` : ""}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export function AppFooter() {
|
export function AppFooter() {
|
||||||
return (
|
return (
|
||||||
<footer className="py-[72px]">
|
<footer className="py-[72px]">
|
||||||
<div className="text-[#626262] text-[16px] leading-[14px] flex flex-row mb-[30px]">
|
<div className="text-[#626262] text-[16px] leading-[14px] flex flex-row mb-[30px]">
|
||||||
<div className="flex-1"></div>
|
<div className="flex-1"></div>
|
||||||
<Link href="/privacy" className="border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]">개인정보처리방침</Link>
|
<div className="border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]">개인정보처리방침</div>
|
||||||
<Link href="/email-deny" className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]">이메일 무단수집거부</Link>
|
<div className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]">이메일 무단수집거부</div>
|
||||||
<Link href="/legal" className=" border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]">책임의 한계와 법적고지</Link>
|
<div className=" border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]">책임의 한계와 법적고지</div>
|
||||||
<Link href="/guide" className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]">이용안내</Link>
|
<div className="hidden lg:block border-r border-dotted border-[#626262] px-[8px] cursor-pointer hover:text-[#2BAF7E]">이용안내</div>
|
||||||
<Link href="/contact" className="px-[8px] cursor-pointer hover:text-[#2BAF7E]">문의하기</Link>
|
<div className="px-[8px] cursor-pointer hover:text-[#2BAF7E]">문의하기</div>
|
||||||
<div className="flex-1"></div>
|
<div className="flex-1"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[#888] text-center font-[Pretendard] text-[16px] font-normal leading-[100%] mt-[24px]">
|
<div className="text-[#888] text-center font-[Pretendard] text-[16px] font-normal leading-[100%] mt-[24px]">
|
||||||
|
|||||||
@@ -2,10 +2,7 @@
|
|||||||
// 클라이언트 훅(useState/useEffect)을 사용하여 세션 표시/로그아웃을 처리합니다.
|
// 클라이언트 훅(useState/useEffect)을 사용하여 세션 표시/로그아웃을 처리합니다.
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SearchBar } from "./SearchBar";
|
import { SearchBar } from "@/app/components/SearchBar";
|
||||||
import useSWR from "swr";
|
|
||||||
import { UserAvatar } from "@/app/components/UserAvatar";
|
|
||||||
import { GradeIcon } from "@/app/components/GradeIcon";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import { SinglePageLogo } from "@/app/components/SinglePageLogo";
|
import { SinglePageLogo } from "@/app/components/SinglePageLogo";
|
||||||
@@ -21,26 +18,11 @@ export function AppHeader() {
|
|||||||
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
const panelRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const blockRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
|
const blockRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
const navItemRefs = 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 [leftPositions, setLeftPositions] = React.useState<Record<string, number>>({});
|
||||||
const [panelHeight, setPanelHeight] = React.useState<number>(0);
|
const [panelHeight, setPanelHeight] = React.useState<number>(0);
|
||||||
const [blockWidths, setBlockWidths] = React.useState<Record<string, number>>({});
|
const [blockWidths, setBlockWidths] = React.useState<Record<string, number>>({});
|
||||||
const closeTimer = React.useRef<number | null>(null);
|
const closeTimer = React.useRef<number | null>(null);
|
||||||
const [navMinWidths, setNavMinWidths] = React.useState<Record<string, number>>({});
|
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();
|
const pathname = usePathname();
|
||||||
@@ -90,40 +72,6 @@ export function AppHeader() {
|
|||||||
return () => window.removeEventListener("categories:reload", onRefresh);
|
return () => window.removeEventListener("categories:reload", onRefresh);
|
||||||
}, [reloadCategories]);
|
}, [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로 메가메뉴 닫기
|
// ESC로 메가메뉴 닫기
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!megaOpen) return;
|
if (!megaOpen) return;
|
||||||
@@ -346,27 +294,22 @@ export function AppHeader() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<header
|
<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">
|
||||||
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]">
|
<div className="flex items-center gap-3 z-[100]">
|
||||||
<button
|
<button
|
||||||
aria-label="메뉴 열기"
|
aria-label="메뉴 열기"
|
||||||
aria-expanded={mobileOpen}
|
aria-expanded={mobileOpen}
|
||||||
aria-controls="mobile-menu"
|
aria-controls="mobile-menu"
|
||||||
onClick={() => setMobileOpen((v) => !v)}
|
onClick={() => setMobileOpen((v) => !v)}
|
||||||
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"
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-neutral-200 xl:hidden"
|
||||||
>
|
>
|
||||||
<span className="flex flex-col items-center justify-center gap-1">
|
<span className="flex flex-col items-center justify-center gap-1">
|
||||||
<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" />
|
||||||
<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" />
|
||||||
<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" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<Link href="/" aria-label="홈" className="shrink-0 flex items-center">
|
<Link href="/" aria-label="홈" className="shrink-0">
|
||||||
<SinglePageLogo width={120} height={28} className="w-20 xl:w-[120px] h-auto" />
|
<SinglePageLogo width={120} height={28} className="w-20 xl:w-[120px] h-auto" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,7 +325,7 @@ export function AppHeader() {
|
|||||||
if (!e.currentTarget.contains(next)) setMegaOpen(false);
|
if (!e.currentTarget.contains(next)) setMegaOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative flex items-center gap-0" ref={navRowRef}>
|
<div className="flex items-center gap-8">
|
||||||
{categories.map((cat, idx) => (
|
{categories.map((cat, idx) => (
|
||||||
<div
|
<div
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
@@ -391,44 +334,31 @@ export function AppHeader() {
|
|||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
navItemRefs.current[cat.slug] = el;
|
navItemRefs.current[cat.slug] = el;
|
||||||
}}
|
}}
|
||||||
|
style={idx === categories.length - 1 ? { minWidth: 120 } : undefined}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={cat.boards?.[0]?.slug ? `/boards/${cat.boards[0].slug}` : `/boards?category=${cat.slug}`}
|
href={cat.boards?.[0]?.slug ? `/boards/${cat.boards[0].slug}` : `/boards?category=${cat.slug}`}
|
||||||
className={`block w-full px-6 py-2 text-sm font-medium transition-colors duration-200 whitespace-nowrap ${
|
className={`block w-full px-2 py-2 text-sm font-medium transition-colors duration-200 hover:text-neutral-900 whitespace-nowrap ${
|
||||||
(megaOpen && openSlug === cat.slug) || activeCategorySlug === cat.slug ? "" : "text-neutral-700"
|
activeCategorySlug === cat.slug ? "text-neutral-900" : "text-neutral-700"
|
||||||
}`}
|
}`}
|
||||||
style={(megaOpen && openSlug === cat.slug) || activeCategorySlug === cat.slug ? { color: "var(--red-50, #F94B37)" } : undefined}
|
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
navRefs.current[cat.slug] = el;
|
navRefs.current[cat.slug] = el;
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<span
|
|
||||||
ref={(el) => {
|
|
||||||
navTextRefs.current[cat.slug] = el;
|
|
||||||
}}
|
|
||||||
className="inline-block"
|
|
||||||
>
|
>
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{/* 공용 선택 인디케이터 바 (Figma 스타일) */}
|
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
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 ${
|
||||||
className="pointer-events-none absolute bottom-0 left-0 transition-all duration-300 ease-out"
|
(megaOpen && openSlug === cat.slug) || activeCategorySlug === cat.slug
|
||||||
style={{
|
? "scale-x-100 opacity-100"
|
||||||
transform: `translateX(${indicatorLeft}px)`,
|
: "scale-x-0 opacity-0 group-hover:opacity-100 group-hover:scale-x-100"
|
||||||
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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
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 ${
|
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 ${
|
||||||
megaOpen ? "opacity-100" : "pointer-events-none opacity-0"
|
megaOpen ? "opacity-100" : "pointer-events-none opacity-0"
|
||||||
}`}
|
}`}
|
||||||
style={{ top: headerBottom }}
|
style={{ top: headerBottom }}
|
||||||
@@ -447,15 +377,13 @@ export function AppHeader() {
|
|||||||
style={{ left: (leftPositions[cat.slug] ?? 0), width: blockWidths[cat.slug] ?? undefined }}
|
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="mb-2 font-semibold text-neutral-800">{cat.name}</div> */}
|
||||||
<div className="mx-auto flex flex-col items-center gap-0 w-full">
|
<div className="mx-auto flex flex-col items-center gap-3 w-full">
|
||||||
{cat.boards.map((b) => (
|
{cat.boards.map((b) => (
|
||||||
<Link
|
<Link
|
||||||
key={b.id}
|
key={b.id}
|
||||||
href={`/boards/${b.slug}`}
|
href={`/boards/${b.slug}`}
|
||||||
className={`rounded px-2 pb-4 text-sm transition-colors duration-150 text-center whitespace-nowrap ${
|
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
|
activeBoardId === b.slug ? "bg-neutral-100 text-neutral-900 font-medium" : "text-neutral-700"
|
||||||
? "text-[var(--red-50,#F94B37)] font-semibold"
|
|
||||||
: "text-neutral-700 hover:text-[var(--red-50,#F94B37)]"
|
|
||||||
}`}
|
}`}
|
||||||
aria-current={activeBoardId === b.slug ? "page" : undefined}
|
aria-current={activeBoardId === b.slug ? "page" : undefined}
|
||||||
>
|
>
|
||||||
@@ -471,130 +399,24 @@ export function AppHeader() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="dummy" className="block"></div>
|
<div id="dummy" className="block"></div>
|
||||||
<div className="hidden xl:flex xl:flex-1 justify-end items-center gap-3">
|
<div className="hidden xl:flex xl:flex-1 justify-end">
|
||||||
<SearchBar/>
|
<SearchBar/>
|
||||||
{authData?.user ? (
|
|
||||||
<>
|
|
||||||
{/* 인사 + 로그아웃을 하나의 배지로 묶어 자연스럽게 표시 */}
|
|
||||||
<div className="inline-flex items-center h-10 rounded-md border border-neutral-300 bg-white overflow-hidden">
|
|
||||||
<Link
|
|
||||||
href="/my-page"
|
|
||||||
aria-label="마이페이지"
|
|
||||||
className="h-full px-3 inline-flex items-center text-sm text-neutral-800 hover:bg-neutral-100 cursor-pointer truncate max-w-[220px]"
|
|
||||||
>
|
|
||||||
{authData.user.nickname}님 안녕하세요
|
|
||||||
</Link>
|
|
||||||
<span aria-hidden className="w-px h-5 bg-neutral-200" />
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await fetch("/api/auth/session", { method: "DELETE" });
|
|
||||||
} finally {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="h-full px-3 text-sm text-neutral-700 hover:bg-neutral-100 focus:outline-none cursor-pointer"
|
|
||||||
aria-label="로그아웃"
|
|
||||||
>
|
|
||||||
로그아웃
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
href={`/login?next=${encodeURIComponent((pathname || "/") + (searchParams?.toString() ? `?${searchParams.toString()}` : ""))}`}
|
|
||||||
className="inline-flex items-center px-3 h-10 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
|
||||||
aria-label="로그인"
|
|
||||||
>
|
|
||||||
로그인
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div
|
{mobileOpen && (
|
||||||
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"}`}
|
<div className="fixed inset-0 h-[100vh] z-40 bg-black/60 backdrop-blur-sm" onClick={() => setMobileOpen(false)}>
|
||||||
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()}>
|
||||||
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 className="mb-3 h-10 flex items-center justify-between">
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* 미니 프로필 패널 */}
|
<SearchBar />
|
||||||
<div className="rounded-xl border border-neutral-200 p-3">
|
|
||||||
{meData?.user ? (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<UserAvatar src={meData.user.profileImage} alt={meData.user.nickname || "프로필"} width={48} height={48} className="rounded-full" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-semibold text-neutral-900 truncate">{meData.user.nickname}</span>
|
|
||||||
<GradeIcon grade={meData.user.grade} width={20} height={20} />
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-neutral-600">Lv.{meData.user.level} · {meData.user.points.toLocaleString()}점</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="text-sm text-neutral-700">로그인이 필요합니다</div>
|
|
||||||
<Link
|
|
||||||
href={`/login?next=${encodeURIComponent((pathname || "/") + (searchParams?.toString() ? `?${searchParams.toString()}` : ""))}`}
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
className="h-9 px-3 inline-flex items-center rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
|
||||||
aria-label="로그인"
|
|
||||||
>
|
|
||||||
로그인
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{meData?.user && (
|
|
||||||
<div className="mt-3 flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await fetch("/api/auth/session", { method: "DELETE" });
|
|
||||||
} finally {
|
|
||||||
setMobileOpen(false);
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="h-9 px-3 inline-flex items-center rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100"
|
|
||||||
aria-label="로그아웃"
|
|
||||||
>
|
|
||||||
로그아웃
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{meData?.user && (
|
|
||||||
<div className="grid grid-cols-3 gap-2 mt-3">
|
|
||||||
<Link href="/my-page?tab=points" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">포인트 히스토리</Link>
|
|
||||||
<Link href="/my-page?tab=posts" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">내가 쓴 글</Link>
|
|
||||||
<Link href="/my-page?tab=comments" onClick={() => setMobileOpen(false)} className="h-9 rounded-md border border-neutral-300 text-xs flex items-center justify-center hover:bg-neutral-100">내가 쓴 댓글</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<SearchBar fullWidth />
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
<div key={cat.id}>
|
<div key={cat.id}>
|
||||||
<div className="mb-2 font-semibold text-neutral-800">{cat.name}</div>
|
<div className="mb-2 font-semibold text-neutral-800">{cat.name}</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{cat.boards.map((b) => (
|
{cat.boards.map((b) => (
|
||||||
<Link
|
<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">
|
||||||
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}
|
{b.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -605,6 +427,7 @@ export function AppHeader() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,246 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"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,365 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { RankIcon1st } from "./RankIcon1st";
|
|
||||||
import { RankIcon2nd } from "./RankIcon2nd";
|
|
||||||
import { RankIcon3rd } from "./RankIcon3rd";
|
|
||||||
import { UserAvatar } from "./UserAvatar";
|
|
||||||
import { ImagePlaceholderIcon } from "./ImagePlaceholderIcon";
|
|
||||||
import { PostList } from "./PostList";
|
|
||||||
import { GradeIcon } from "./GradeIcon";
|
|
||||||
|
|
||||||
type BoardMeta = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
mainTypeKey?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UserData = {
|
|
||||||
userId: string;
|
|
||||||
nickname: string | null;
|
|
||||||
points: number;
|
|
||||||
profileImage: string | null;
|
|
||||||
grade: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PostData = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
createdAt: Date;
|
|
||||||
content?: string | null;
|
|
||||||
attachments?: { url: string }[];
|
|
||||||
stat?: { recommendCount: number | null; commentsCount: number | null };
|
|
||||||
};
|
|
||||||
|
|
||||||
type BoardPanelData = {
|
|
||||||
board: BoardMeta;
|
|
||||||
categoryName: string;
|
|
||||||
siblingBoards: BoardMeta[];
|
|
||||||
specialRankUsers?: UserData[];
|
|
||||||
previewPosts?: PostData[];
|
|
||||||
textPosts?: PostData[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function BoardPanelClient({
|
|
||||||
initialBoardId,
|
|
||||||
boardsData
|
|
||||||
}: {
|
|
||||||
initialBoardId: string;
|
|
||||||
boardsData: BoardPanelData[];
|
|
||||||
}) {
|
|
||||||
const [selectedBoardId, setSelectedBoardId] = useState(initialBoardId);
|
|
||||||
|
|
||||||
// 데이터가 비어있을 때 안전 처리
|
|
||||||
if (!boardsData || boardsData.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="h-full min-h-0 flex items-center justify-center rounded-xl bg-white text-sm text-neutral-500">
|
|
||||||
선택된 게시판이 없습니다.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 선택된 보드가 목록에 없으면 첫 번째 보드로 동기화
|
|
||||||
useEffect(() => {
|
|
||||||
const exists = boardsData.some((bd) => bd.board.id === selectedBoardId);
|
|
||||||
if (!exists) {
|
|
||||||
setSelectedBoardId(boardsData[0].board.id);
|
|
||||||
}
|
|
||||||
}, [boardsData, selectedBoardId]);
|
|
||||||
|
|
||||||
// 선택된 게시판 데이터 찾기
|
|
||||||
const selectedBoardData = boardsData.find(bd => bd.board.id === selectedBoardId) || boardsData[0];
|
|
||||||
const { board, categoryName, siblingBoards } = selectedBoardData;
|
|
||||||
|
|
||||||
const isNewWithin1Hour = (createdAt: Date | string | number | null | undefined): boolean => {
|
|
||||||
if (!createdAt) return false;
|
|
||||||
const t = new Date(createdAt).getTime();
|
|
||||||
if (Number.isNaN(t)) return false;
|
|
||||||
return (Date.now() - t) <= 60 * 60 * 1000;
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatDateYmd(d: Date) {
|
|
||||||
const date = new Date(d);
|
|
||||||
const yyyy = date.getFullYear();
|
|
||||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
const dd = String(date.getDate()).padStart(2, "0");
|
|
||||||
return `${yyyy}.${mm}.${dd}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripHtml(html: string | null | undefined): string {
|
|
||||||
if (!html) return "";
|
|
||||||
// HTML 태그 제거
|
|
||||||
return html.replace(/<[^>]*>/g, "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractImageFromContent(content: string | null | undefined): string | null {
|
|
||||||
if (!content) return null;
|
|
||||||
// img 태그에서 src 속성 추출
|
|
||||||
const imgMatch = content.match(/<img[^>]+src=["']([^"']+)["'][^>]*>/i);
|
|
||||||
if (imgMatch && imgMatch[1]) {
|
|
||||||
return imgMatch[1];
|
|
||||||
}
|
|
||||||
// figure 안의 img 태그도 확인
|
|
||||||
const figureMatch = content.match(/<figure[^>]*>[\s\S]*?<img[^>]+src=["']([^"']+)["'][^>]*>/i);
|
|
||||||
if (figureMatch && figureMatch[1]) {
|
|
||||||
return figureMatch[1];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTextMain = board.mainTypeKey === "main_text";
|
|
||||||
const isSpecialRank = board.mainTypeKey === "main_special_rank";
|
|
||||||
const isPreview = board.mainTypeKey === "main_preview";
|
|
||||||
|
|
||||||
// 특수 랭킹 타입 렌더링
|
|
||||||
if (isSpecialRank && selectedBoardData.specialRankUsers) {
|
|
||||||
return (
|
|
||||||
<div className="h-full min-h-0 flex flex-col">
|
|
||||||
<div className="content-stretch flex flex-col md:flex-row gap-[8px] md:gap-[16px] 2xl:gap-[30px] items-start w-full mb-2">
|
|
||||||
<div className="flex items-center gap-[8px] shrink-0">
|
|
||||||
<div className="text-[20px] leading-[20px] md:text-[24px] md:leading-[24px] 2xl:text-[30px] 2xl:leading-[30px] text-[#5c5c5c]">{categoryName || board.name}</div>
|
|
||||||
<Link href={`/boards/${board.slug}`} aria-label={`${board.name} 게시판으로 이동`} className="shrink-0 group">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 19 19" fill="none" className="block group-hover:hidden">
|
|
||||||
<path d="M10.25 12.5L13.25 9.5M13.25 9.5L10.25 6.5M13.25 9.5L5.75 9.5M18.5 9.5C18.5 14.4706 14.4706 18.5 9.5 18.5C4.52944 18.5 0.5 14.4706 0.5 9.5C0.5 4.52944 4.52944 0.5 9.5 0.5C14.4706 0.5 18.5 4.52944 18.5 9.5Z" stroke="#8C8C8C" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" className="hidden group-hover:block">
|
|
||||||
<path fillRule="evenodd" clipRule="evenodd" d="M9.75 0C4.36522 0 0 4.36522 0 9.75C0 15.1348 4.36522 19.5 9.75 19.5C15.1348 19.5 19.5 15.1348 19.5 9.75C19.5 4.36522 15.1348 0 9.75 0ZM14.0303 10.2803C14.171 10.1397 14.25 9.94891 14.25 9.75C14.25 9.55109 14.171 9.36032 14.0303 9.21967L11.0303 6.21967C10.7374 5.92678 10.2626 5.92678 9.96967 6.21967C9.67678 6.51256 9.67678 6.98744 9.96967 7.28033L11.6893 9L6 9C5.58579 9 5.25 9.33578 5.25 9.75C5.25 10.1642 5.58579 10.5 6 10.5L11.6893 10.5L9.96967 12.2197C9.67678 12.5126 9.67678 12.9874 9.96967 13.2803C10.2626 13.5732 10.7374 13.5732 11.0303 13.2803L14.0303 10.2803Z" fill="#5C5C5C" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-[6px] md:gap-[6px] 2xl:gap-[8px] flex-wrap w-full md:w-auto min-w-0 md:flex-1 md:overflow-x-auto md:flex-nowrap no-scrollbar">
|
|
||||||
{siblingBoards.map((sb) => (
|
|
||||||
<button
|
|
||||||
key={sb.id}
|
|
||||||
onClick={() => setSelectedBoardId(sb.id)}
|
|
||||||
className={`px-3 py-1.5 text-[12px] rounded-[12px] md:px-3.5 md:py-[6px] md:text-[13px] md:rounded-[12px] 2xl:px-[16px] 2xl:py-[8px] 2xl:text-[14px] 2xl:rounded-[14px] shrink-0 cursor-pointer ${sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5] hover:bg-[#5c5c5c] hover:text-white hover:border-[#5c5c5c] transition-colors"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{sb.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden h-full min-h-0 flex flex-col">
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden p-0">
|
|
||||||
<div className="px-[0px] pt-[6px] pb-[6px]">
|
|
||||||
<div className="flex flex-col gap-[6px]">
|
|
||||||
{selectedBoardData.specialRankUsers.map((user, idx) => {
|
|
||||||
const rank = idx + 1;
|
|
||||||
return (
|
|
||||||
<Link href="/boards/ranking" key={user.userId} className=" mx-[4px] flex h-[76px] items-center rounded-[10px] overflow-hidden bg-white hover:bg-neutral-50 transition-shadow hover:[box-shadow:0_0_2px_0_var(--color-alpha-shadow2,_rgba(0,0,0,0.08)),_0_var(--shadow-y-3,_8px)_var(--shadow-blur-3,_16px)_0_var(--color-alpha-shadow3,_rgba(0,0,0,0.12))] pl-[12px]">
|
|
||||||
<div className="relative shrink-0">
|
|
||||||
<div className="h-[56px] w-[56px] bg-[#d5d5d5] overflow-hidden rounded-full">
|
|
||||||
<UserAvatar
|
|
||||||
src={user.profileImage}
|
|
||||||
alt={user.nickname || "프로필"}
|
|
||||||
width={56}
|
|
||||||
height={56}
|
|
||||||
className="w-full h-full object-cover rounded-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="absolute -right-1 -bottom-1 w-[22px] h-[22px] flex items-center justify-center z-10">
|
|
||||||
<GradeIcon grade={user.grade} width={22} height={22} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 flex items-center gap-[6px] px-[12px] md:px-[12px] py-[8px] min-w-0">
|
|
||||||
<div className="flex flex-col gap-[4px] min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-[6px]">
|
|
||||||
{
|
|
||||||
(rank === 1 || rank === 2 || rank === 3) && (
|
|
||||||
<div className="relative w-[22px] h-[22px] shrink-0">
|
|
||||||
{rank === 1 && <RankIcon1st />}
|
|
||||||
{rank === 2 && <RankIcon2nd />}
|
|
||||||
{rank === 3 && <RankIcon3rd />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
<div className="bg-white border border-[#d5d5d5] px-[8px] py-[2px] rounded-[8px] text-[11px] text-[#5c5c5c] shrink-0">
|
|
||||||
{rank}위
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-[13px] font-medium text-[#5c5c5c] truncate leading-[16px]">
|
|
||||||
{user.nickname || "익명"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-[16px] flex items-center gap-[4px] shrink-0">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" className="shrink-0">
|
|
||||||
<path d="M8.17377 0.0481988C8.1834 0.0326271 8.19596 0.0201252 8.21037 0.0117499C8.22478 0.00337451 8.24062 -0.000628204 8.25656 7.99922e-05C8.2725 0.000788189 8.28806 0.0061865 8.30194 0.0158187C8.31582 0.0254509 8.3276 0.0390338 8.33629 0.0554196L11.3323 5.55973C11.3575 5.60512 11.4038 5.62472 11.4468 5.60718L15.8683 3.80198C15.9441 3.77104 16.0174 3.85666 15.9963 3.95053L14.2584 16H1.74226L0.00344328 3.95156C-0.00125237 3.93019 -0.0011435 3.90765 0.00375838 3.88635C0.00866025 3.86505 0.0181727 3.84576 0.0312889 3.83054C0.0444051 3.81532 0.0606369 3.80472 0.0782661 3.79988C0.0958953 3.79503 0.114266 3.79612 0.131434 3.80302L4.55889 5.61131C4.59846 5.62678 4.64309 5.61131 4.66835 5.57005L8.17461 0.0481988H8.17377Z" fill="#FFB625" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-[12px] font-semibold text-[#5c5c5c] leading-[16px]">{user.points.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 미리보기 타입 렌더링
|
|
||||||
if (isPreview && selectedBoardData.previewPosts) {
|
|
||||||
return (
|
|
||||||
<div className="h-full min-h-0 flex flex-col">
|
|
||||||
<div className="content-stretch flex flex-col md:flex-row gap-[8px] md:gap-[16px] lg:gap-[30px] items-start w-full mb-2">
|
|
||||||
<div className="flex items-center gap-[8px] shrink-0">
|
|
||||||
<div className="text-[20px] leading-[20px] md:text-[24px] md:leading-[24px] lg:text-[30px] lg:leading-[30px] text-[#5c5c5c]">{categoryName || board.name}</div>
|
|
||||||
<Link href={`/boards/${board.slug}`} aria-label={`${board.name} 게시판으로 이동`} className="shrink-0 group">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 19 19" fill="none" className="block group-hover:hidden">
|
|
||||||
<path d="M10.25 12.5L13.25 9.5M13.25 9.5L10.25 6.5M13.25 9.5L5.75 9.5M18.5 9.5C18.5 14.4706 14.4706 18.5 9.5 18.5C4.52944 18.5 0.5 14.4706 0.5 9.5C0.5 4.52944 4.52944 0.5 9.5 0.5C14.4706 0.5 18.5 4.52944 18.5 9.5Z" stroke="#8C8C8C" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" className="hidden group-hover:block">
|
|
||||||
<path fillRule="evenodd" clipRule="evenodd" d="M9.75 0C4.36522 0 0 4.36522 0 9.75C0 15.1348 4.36522 19.5 9.75 19.5C15.1348 19.5 19.5 15.1348 19.5 9.75C19.5 4.36522 15.1348 0 9.75 0ZM14.0303 10.2803C14.171 10.1397 14.25 9.94891 14.25 9.75C14.25 9.55109 14.171 9.36032 14.0303 9.21967L11.0303 6.21967C10.7374 5.92678 10.2626 5.92678 9.96967 6.21967C9.67678 6.51256 9.67678 6.98744 9.96967 7.28033L11.6893 9L6 9C5.58579 9 5.25 9.33578 5.25 9.75C5.25 10.1642 5.58579 10.5 6 10.5L11.6893 10.5L9.96967 12.2197C9.67678 12.5126 9.67678 12.9874 9.96967 13.2803C10.2626 13.5732 10.7374 13.5732 11.0303 13.2803L14.0303 10.2803Z" fill="#5C5C5C" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-[6px] md:gap-[6px] lg:gap-[8px] flex-wrap w-full md:w-auto min-w-0 md:flex-1 md:overflow-x-auto md:flex-nowrap no-scrollbar">
|
|
||||||
{siblingBoards.map((sb) => (
|
|
||||||
<button
|
|
||||||
key={sb.id}
|
|
||||||
onClick={() => setSelectedBoardId(sb.id)}
|
|
||||||
className={`px-3 py-1.5 text-[12px] rounded-[12px] md:px-3.5 md:py-[6px] md:text-[13px] md:rounded-[12px] lg:px-[16px] lg:py-[8px] lg:text-[14px] lg:rounded-[14px] shrink-0 cursor-pointer ${sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5] hover:bg-[#5c5c5c] hover:text-white hover:border-[#5c5c5c] transition-colors"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{sb.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className=" overflow-hidden h-full min-h-0 flex flex-col">
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden p-0">
|
|
||||||
<div className="px-[0px] pt-[6px] pb-[6px]">
|
|
||||||
<div className="flex flex-col gap-[6px]">
|
|
||||||
{selectedBoardData.previewPosts.map((post) => {
|
|
||||||
// attachments에서 이미지를 먼저 찾고, 없으면 content에서 추출
|
|
||||||
const firstImage = post.attachments?.[0]?.url || extractImageFromContent(post.content);
|
|
||||||
return (
|
|
||||||
<Link key={post.id} href={`/posts/${post.id}`} className="mx-[4px] group flex h-[72px] items-start rounded-[10px] overflow-hidden bg-white hover:bg-neutral-50 transition-shadow hover:[box-shadow:0_0_2px_0_var(--color-alpha-shadow2,_rgba(0,0,0,0.08)),_0_var(--shadow-y-3,_8px)_var(--shadow-blur-3,_16px)_0_var(--color-alpha-shadow3,_rgba(0,0,0,0.12))]">
|
|
||||||
<div className="h-[72px] w-[90px] relative shrink-0 bg-[#ededed] overflow-hidden">
|
|
||||||
{firstImage ? (
|
|
||||||
<img
|
|
||||||
src={firstImage}
|
|
||||||
alt={stripHtml(post.title)}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] flex items-center justify-center">
|
|
||||||
<ImagePlaceholderIcon width={32} height={32} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 flex items-center gap-[6px] px-[10px] md:px-[10px] py-[6px] min-w-0">
|
|
||||||
<div className="flex flex-col gap-[4px] min-w-0 flex-1">
|
|
||||||
<div className="bg-white border border-[#d5d5d5] px-[8px] py-[2px] rounded-[8px] text-[11px] text-[#5c5c5c] shrink-0 w-fit">
|
|
||||||
{board.name}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-[4px] overflow-hidden">
|
|
||||||
{isNewWithin1Hour(post.createdAt) && (
|
|
||||||
<div className="relative w-[16px] h-[16px] shrink-0">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
||||||
<path fillRule="evenodd" clipRule="evenodd" d="M1 8C1 4.5691 4.26184 2 8 2C11.7382 2 15 4.5691 15 8C15 11.4309 11.7382 14 8 14C7.57698 14 7.16215 13.9679 6.7588 13.9061C5.85852 14.4801 4.81757 14.8544 3.6995 14.9654C3.41604 14.9936 3.1411 14.8587 2.98983 14.6174C2.83857 14.376 2.83711 14.0698 2.98605 13.8269C3.21838 13.4482 3.38055 13.0221 3.45459 12.5659C1.97915 11.4858 1 9.86014 1 8Z" fill="#F45F00" />
|
|
||||||
</svg>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="text-[13px] leading-[18px] text-[#5c5c5c] truncate group-hover:text-[var(--red-50,#F94B37)] group-hover:font-[700]" style={{ fontFamily: "var(--font-family-Font-1, Pretendard)" }}>{stripHtml(post.title)}</span>
|
|
||||||
{(post.stat?.commentsCount ?? 0) > 0 && (
|
|
||||||
<span className="ml-1 text-[11px] text-[#f45f00] font-bold shrink-0">[{post.stat?.commentsCount}]</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="h-[16px] relative">
|
|
||||||
<span className="absolute top-1/2 translate-y-[-50%] text-[10px] leading-[10px] tracking-[-0.24px] text-[#8c8c8c]">
|
|
||||||
{formatDateYmd(post.createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 텍스트 메인 타입 또는 기본 타입 렌더링
|
|
||||||
return (
|
|
||||||
<div className="h-full min-h-0 flex flex-col">
|
|
||||||
<div className="content-stretch flex flex-col md:flex-row gap-[8px] md:gap-[16px] 2xl:gap-[30px] items-start w-full mb-2">
|
|
||||||
<div className="flex items-center gap-[8px] shrink-0">
|
|
||||||
<div className="text-[20px] leading-[20px] md:text-[24px] md:leading-[24px] 2xl:text-[30px] 2xl:leading-[30px] text-[#5c5c5c]">{categoryName || board.name}</div>
|
|
||||||
<Link href={`/boards/${board.slug}`} aria-label={`${board.name} 게시판으로 이동`} className="shrink-0 group">
|
|
||||||
{/* 기본 아이콘 */}
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 19 19" fill="none" className="block group-hover:hidden">
|
|
||||||
<path d="M10.25 12.5L13.25 9.5M13.25 9.5L10.25 6.5M13.25 9.5L5.75 9.5M18.5 9.5C18.5 14.4706 14.4706 18.5 9.5 18.5C4.52944 18.5 0.5 14.4706 0.5 9.5C0.5 4.52944 4.52944 0.5 9.5 0.5C14.4706 0.5 18.5 4.52944 18.5 9.5Z" stroke="#8C8C8C" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
{/* 호버 아이콘 */}
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" className="hidden group-hover:block">
|
|
||||||
<path fillRule="evenodd" clipRule="evenodd" d="M9.75 0C4.36522 0 0 4.36522 0 9.75C0 15.1348 4.36522 19.5 9.75 19.5C15.1348 19.5 19.5 15.1348 19.5 9.75C19.5 4.36522 15.1348 0 9.75 0ZM14.0303 10.2803C14.171 10.1397 14.25 9.94891 14.25 9.75C14.25 9.55109 14.171 9.36032 14.0303 9.21967L11.0303 6.21967C10.7374 5.92678 10.2626 5.92678 9.96967 6.21967C9.67678 6.51256 9.67678 6.98744 9.96967 7.28033L11.6893 9L6 9C5.58579 9 5.25 9.33578 5.25 9.75C5.25 10.1642 5.58579 10.5 6 10.5L11.6893 10.5L9.96967 12.2197C9.67678 12.5126 9.67678 12.9874 9.96967 13.2803C10.2626 13.5732 10.7374 13.5732 11.0303 13.2803L14.0303 10.2803Z" fill="#5C5C5C" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-[6px] md:gap-[6px] 2xl:gap-[8px] flex-wrap w-full md:w-auto min-w-0 md:flex-1 md:overflow-x-auto md:flex-nowrap no-scrollbar">
|
|
||||||
{siblingBoards.map((sb) => (
|
|
||||||
<button
|
|
||||||
key={sb.id}
|
|
||||||
onClick={() => setSelectedBoardId(sb.id)}
|
|
||||||
className={`px-3 py-1.5 text-[12px] rounded-[12px] md:px-3.5 md:py-[6px] md:text-[13px] md:rounded-[12px] 2xl:px-[16px] 2xl:py-[8px] 2xl:text-[14px] 2xl:rounded-[14px] shrink-0 cursor-pointer ${sb.id === selectedBoardId ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5] hover:bg-[#5c5c5c] hover:text-white hover:border-[#5c5c5c] transition-colors"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{sb.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col bg-white">
|
|
||||||
{!isTextMain && (
|
|
||||||
<div className="px-3 py-2 border-b border-neutral-200 flex items-center justify-between">
|
|
||||||
<Link href={`/boards/${board.slug}`} className="text-lg md:text-xl font-bold text-neutral-800 truncate">{board.name}</Link>
|
|
||||||
<Link href={`/boards/${board.slug}`} className="text-xs px-3 py-1 rounded-full border border-neutral-300 text-neutral-700 hover:bg-neutral-100">더보기</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden p-0">
|
|
||||||
{isTextMain && selectedBoardData.textPosts ? (
|
|
||||||
<div className="bg-white px-[24px] pt-[8px] pb-[16px]">
|
|
||||||
<ul className="min-h-[326px]">
|
|
||||||
{selectedBoardData.textPosts.map((p) => (
|
|
||||||
<li key={p.id} className="border-b border-[#ededed] h-[28px] pl-0 pr-[4px] pt-0 pb-0">
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<Link href={`/posts/${p.id}`} className="group flex items-center gap-[4px] h-[24px] overflow-hidden flex-1 min-w-0 cursor-pointer">
|
|
||||||
{isNewWithin1Hour(p.createdAt) && (
|
|
||||||
<div className="relative w-[16px] h-[16px] shrink-0">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
||||||
<path fillRule="evenodd" clipRule="evenodd" d="M1 8C1 4.5691 4.26184 2 8 2C11.7382 2 15 4.5691 15 8C15 11.4309 11.7382 14 8 14C7.57698 14 7.16215 13.9679 6.7588 13.9061C5.85852 14.4801 4.81757 14.8544 3.6995 14.9654C3.41604 14.9936 3.1411 14.8587 2.98983 14.6174C2.83857 14.376 2.83711 14.0698 2.98605 13.8269C3.21838 13.4482 3.38055 13.0221 3.45459 12.5659C1.97915 11.4858 1 9.86014 1 8Z" fill="#F45F00" />
|
|
||||||
</svg>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="text-[14px] leading-[20px] text-[#5c5c5c] truncate max-w-[calc(100vw-280px)] group-hover:text-[var(--red-50,#F94B37)] group-hover:font-[700]" style={{ fontFamily: "var(--font-family-Font-1, Pretendard)" }}>{stripHtml(p.title)}</span>
|
|
||||||
{(p.stat?.commentsCount ?? 0) > 0 && (
|
|
||||||
<span className="text-[14px] text-[#f45f00] font-bold">[{p.stat?.commentsCount}]</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<span className="text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]">{formatDateYmd(p.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<PostList key={board.id} boardId={board.id} sort="recent" titleHoverOrange pageSizeOverride={16} compact />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||