Compare commits

...

68 Commits

Author SHA1 Message Date
koreacomp5
afc714022f 인기글 추가
All checks were successful
deploy-on-main / deploy (push) Successful in 28s
2025-11-28 06:12:58 +09:00
koreacomp5
c85450ce37 board notfound
All checks were successful
deploy-on-main / deploy (push) Successful in 37s
2025-11-28 05:29:34 +09:00
koreacomp5
c5bc8f5b49 check
All checks were successful
deploy-on-main / deploy (push) Successful in 26s
2025-11-10 21:58:36 +09:00
koreacomp5
14c80baeec ㄹㄹ
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-10 13:44:05 +09:00
koreacomp5
5287611bf7 test 제거
Some checks failed
deploy-on-main / deploy (push) Failing after 21s
2025-11-10 13:33:29 +09:00
koreacomp5
cb2d1f34d3 admin 권한 세팅
Some checks failed
deploy-on-main / deploy (push) Failing after 21s
2025-11-10 11:26:00 +09:00
koreacomp5
97c8e1c9fb build check
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-10 01:58:06 +09:00
koreacomp5
5485da4029 출석관련
Some checks failed
deploy-on-main / deploy (push) Failing after 22s
2025-11-10 01:56:44 +09:00
koreacomp5
b579b32138 컴잇
Some checks failed
deploy-on-main / deploy (push) Failing after 23s
2025-11-10 01:39:44 +09:00
koreacomp5
4337a8f69a fix
Some checks failed
deploy-on-main / deploy (push) Failing after 22s
2025-11-10 00:04:17 +09:00
koreacomp5
a007ac11ce test
Some checks failed
deploy-on-main / deploy (push) Failing after 22s
2025-11-09 22:05:22 +09:00
koreacomp5
34e831f738 cc
All checks were successful
deploy-on-main / deploy (push) Successful in 28s
2025-11-09 20:02:18 +09:00
koreacomp5
cfbb3d50ee 수정
Some checks failed
deploy-on-main / deploy (push) Failing after 22s
2025-11-09 19:53:42 +09:00
koreacomp5
1c2222da67 적용적용
Some checks failed
deploy-on-main / deploy (push) Failing after 23s
2025-11-08 01:21:44 +09:00
koreacomp5
bb71b892ca 중간
Some checks failed
deploy-on-main / deploy (push) Failing after 21s
2025-11-07 23:41:52 +09:00
koreacomp5
ab81a3da3d ??
Some checks failed
deploy-on-main / deploy (push) Failing after 22s
2025-11-07 21:36:34 +09:00
mota
5f72e6ce7c Merge branch 'imsi'
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-06 11:22:10 +09:00
mota
1ec2df27b0 imsi commit 2025-11-06 11:18:55 +09:00
koreacomp5
1fb859fdf9 메인화면 게시글 뷰 컨테이너 라디우스
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-06 00:04:40 +09:00
koreacomp5
e91085b4ed 메인 게시판뷰 디자인 디테일 2025-11-06 00:02:13 +09:00
koreacomp5
2d722a89c7 시드 또수정
All checks were successful
deploy-on-main / deploy (push) Successful in 28s
2025-11-05 23:50:01 +09:00
koreacomp5
c348fb55fa seed update
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-05 23:47:44 +09:00
koreacomp5
c7d5679788 사이드바 수정
All checks were successful
deploy-on-main / deploy (push) Successful in 27s
2025-11-05 23:45:13 +09:00
koreacomp5
a646dfd09f 메인화면 모바일 등등
All checks were successful
deploy-on-main / deploy (push) Successful in 28s
2025-11-05 23:38:35 +09:00
koreacomp5
4d1e7343c3 Merge branch 'homework'
All checks were successful
deploy-on-main / deploy (push) Successful in 35s
2025-11-05 23:05:38 +09:00
koreacomp5
808fe5fc68 header 2025-11-05 23:03:53 +09:00
koreacomp5
7d9c241d17 메인페이지 더수정 homework 2025-11-05 22:49:25 +09:00
koreacomp5
5b8749d11f 메인스타일, 호버 등 2025-11-05 22:27:29 +09:00
koreacomp5
e518c988b2 메인페이지 게시판뷰 바로가기 버튼 2025-11-05 21:17:14 +09:00
koreacomp5
12044587c9 homework1 2025-11-05 21:07:49 +09:00
ecf2dab35c last check
All checks were successful
deploy-on-main / deploy (push) Successful in 28s
2025-11-04 15:34:58 +00:00
1016d8717c deploytest002
All checks were successful
deploy-on-main / deploy (push) Successful in 40s
2025-11-04 15:33:52 +00:00
e7dd2cca00 deploytest 001
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:31:31 +00:00
34f9f0cb32 deploy.yml
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:25:23 +00:00
30c66158d5 123
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:22:52 +00:00
69c54fad1d deploytest2
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:20:26 +00:00
c20599164e deploy test
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:17:37 +00:00
664c72fa41 runnerfile commit
Some checks failed
deploy-on-main / deploy (push) Failing after 1s
2025-11-04 15:12:04 +00:00
486a18451b new address check2
Some checks failed
deploy-on-main / deploy (push) Failing after 8s
2025-11-04 14:48:49 +00:00
koreacomp5
9bfbafcec1 new address check
Some checks failed
deploy-on-main / deploy (push) Has been cancelled
2025-11-04 23:48:13 +09:00
mota
2c9898be90 test
Some checks failed
deploy-on-main / deploy (push) Has been cancelled
2025-11-04 17:28:17 +09:00
34b2739405 deploy fix
Some checks failed
deploy-on-main / deploy (push) Has been cancelled
2025-11-04 08:27:47 +00:00
4aaa542ded build error fix
Some checks failed
deploy-on-main / deploy (push) Has been cancelled
2025-11-04 08:20:03 +00:00
1049d029b4 deploysetting
Some checks failed
deploy-on-main / deploy (push) Has been cancelled
2025-11-04 08:01:05 +00:00
koreacomp5
f4e46c39fb appheader 2025-11-03 10:22:44 +09:00
mota
fadd402e63 123 2025-11-02 15:13:03 +09:00
mota
b10d41532b 123 2025-11-02 13:32:19 +09:00
mota
2047e044d5 Merge branch 'subwork' 2025-11-02 12:08:00 +09:00
mota
2c86f2d515 메인페이지 2025-11-02 12:07:11 +09:00
mota
870da3838a 네브바 2025-11-02 12:06:55 +09:00
mota
11726858cc Merge branch 'subwork' 2025-11-02 11:35:54 +09:00
mota
1ced95f218 main 2025-11-02 11:34:47 +09:00
mota
9e02aa3a88 글쓰기 2025-11-02 11:33:44 +09:00
mota
9ff08d3e58 dd 2025-11-02 11:01:18 +09:00
koreacomp5
e0aacab1d1 Merge branch 'mainwork' 2025-11-02 07:02:27 +09:00
koreacomp5
58af463585 ㅇㅇ 2025-11-02 07:01:42 +09:00
koreacomp5
d54ad82095 Merge branch 'subwork' into mainwork 2025-11-02 04:59:21 +09:00
koreacomp5
9c28d50890 main 2025-11-02 04:59:09 +09:00
koreacomp5
0bf270d884 sub 2025-11-02 04:59:04 +09:00
koreacomp5
c6e60cd34d Merge branch 'mainwork' into subwork 2025-11-02 04:40:04 +09:00
koreacomp5
c7f7492b9e Merge branch 'subwork' into mainwork 2025-11-02 04:39:42 +09:00
koreacomp5
4d310346c1 main 2025-11-02 04:39:28 +09:00
koreacomp5
cc373f53fe sub 2025-11-02 04:39:23 +09:00
koreacomp5
d057ebef4a 게시판 유형 삭제 2025-11-02 03:12:42 +09:00
koreacomp5
0bf18968ad ㄱㄱ 2025-11-02 02:46:20 +09:00
koreacomp5
27cf98eef2 디자인디테일 2025-11-01 23:16:22 +09:00
koreacomp5
f84111b9cc prisma update 2025-11-01 11:38:25 +09:00
mota
187cb9b283 last 2025-10-31 20:05:09 +09:00
206 changed files with 8466 additions and 949 deletions

18
.cursor/.prompt/1101.md Normal file
View File

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

37
.cursor/.prompt/new.md Normal file
View File

@@ -0,0 +1,37 @@
7.게시판 열 구분선 잘보이게 요청 / 모바일 최적화 요청 및 구분선,높이 조절 요청
8.글삭제기능 어드민추가
9-1.시크로드 참고 - 제휴업체 리스트, 지역별 / 제휴업체 정보 생성 요청 (제휴업체 프로필,출근부)
9-2.제휴업체 및 프로필 등록은, 권한을부여한 제휴업체가 직접 등록하도록 해야합니다
9-3.제휴업체 등록 주인이하면안됨, 시크로드 - 제휴문의 - 제휴문의글쓰기 참고요청
제휴문의 게시판 ( 관리자 승인후) → 이동
제휴업소 리스트 게시판 (노출됨)"
10.배너관리 이미지 사이즈 규격 및 제휴업체 등록시 이미지 사이즈 규격
11,게시판, 주간인기글 , 일간인기글 추가 요청
12.https://search.google.com/search-console/welcome 등록요청
13.메인페이지 제휴업체 배너 이미지 규격작게 수정요청, 한번에 4개이상 정도 보이는 정도
14.메인페이지 제휴업체 클릭시 제휴업체 카테고리 하이퍼연결 추가 요청
15.레벨별 아이콘 페이지 / https://seekrod.co.kr/bbs/page.php?hid=point 참고
16. 게시글 상단고정 기능
17. 메인화면 큰 카테고리 빼기
18. google SEO: header, meta, description 동적설정
2. 회색 배경 영역(비밀글, 글자수)이 글 작성 영역 안에 있어야 함
로그인 프로세스 수정: 로그인시 로그인 팝업 모달
로그인 로그오프 시, 메인 프로필 디자인 누락되서 추가(지금상태로 가도 무상관)
1. 게시글에서 목록돌아가는 버튼 디자인과 다름
2. 게시글에서 제목, 내용, 댓글 디자인 다름
3. 게시글에서 최하단에 게시글리스트 부분 디자인 다
4. 게시글 리스트에서 리스트버튼 하단 divider 색상이 너무 연함
5. 표시개수 필요없을 거 같은데, 그냥 고정해버리죠?
9. 게시글 이미지 배치사이즈
10. 외부접속 가입없어도 가능
11. 글뷰에서 게시글 리스트로
12. 게시글 리스트 디자인
13. 포인트 규칙
14. 게시판권한 확인
15.메인뷰 3열 사이즈 변경
16. 로그인 안됐을때 카드 휑함
17.이미지 사이즈 미리불러오기

View File

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

1
.gitignore vendored
View File

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

13
.runner Normal file
View File

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

6
deploytest.txt Normal file
View File

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

18
ecosystem.config.js Normal file
View File

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

3
middleware.ts Normal file
View File

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

View File

@@ -5,15 +5,13 @@
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build --turbopack", "build": "next build --turbopack",
"start": "next start", "start": "next start --port 3100",
"lint": "biome check", "lint": "biome check",
"format": "biome format --write", "format": "biome format --write",
"prisma:generate": "prisma generate", "migrate": "prisma migrate dev",
"prisma:migrate": "prisma migrate dev", "studio": "prisma studio",
"prisma:studio": "prisma studio", "seed": "node prisma/seed.js",
"prisma:db:push": "prisma db push", "dbforce": "prisma migrate reset --force"
"prisma:seed": "node prisma/seed.js",
"prisma:erd": "prisma generate"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.17.0", "@prisma/client": "^6.17.0",

View File

@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "partner_shops" (
"id" TEXT NOT NULL PRIMARY KEY,
"region" TEXT NOT NULL,
"name" TEXT NOT NULL,
"address" TEXT NOT NULL,
"imageUrl" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE INDEX "partner_shops_active_sortOrder_idx" ON "partner_shops"("active", "sortOrder");
-- CreateIndex
CREATE UNIQUE INDEX "partner_shops_name_region_key" ON "partner_shops"("name", "region");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "partners" ADD COLUMN "imageUrl" TEXT;

View File

@@ -0,0 +1,23 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_partners" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
"latitude" REAL NOT NULL,
"longitude" REAL NOT NULL,
"address" TEXT,
"imageUrl" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_partners" ("address", "category", "createdAt", "id", "imageUrl", "latitude", "longitude", "name", "updatedAt") SELECT "address", "category", "createdAt", "id", "imageUrl", "latitude", "longitude", "name", "updatedAt" FROM "partners";
DROP TABLE "partners";
ALTER TABLE "new_partners" RENAME TO "partners";
CREATE UNIQUE INDEX "partners_name_key" ON "partners"("name");
CREATE INDEX "partners_category_idx" ON "partners"("category");
CREATE INDEX "partners_sortOrder_idx" ON "partners"("sortOrder");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,15 @@
-- Add points/level/grade columns to users
PRAGMA foreign_keys=OFF;
-- points
ALTER TABLE "users" ADD COLUMN "points" INTEGER NOT NULL DEFAULT 0;
-- level
ALTER TABLE "users" ADD COLUMN "level" INTEGER NOT NULL DEFAULT 1;
-- grade (0~10 권장, DB 레벨에서는 정수 제약만, 범위는 앱에서 검증)
ALTER TABLE "users" ADD COLUMN "grade" INTEGER NOT NULL DEFAULT 0;
PRAGMA foreign_keys=ON;

View File

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

View File

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

View File

@@ -5,10 +5,12 @@ generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
/*
generator erd { generator erd {
provider = "prisma-erd-generator" provider = "prisma-erd-generator"
output = "../public/erd.svg" output = "../public/erd.svg"
} }
*/
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"
@@ -22,10 +24,6 @@ enum BoardStatus {
archived archived
} }
enum BoardType {
general
special
}
enum AccessLevel { enum AccessLevel {
public // 비회원도 접근 public // 비회원도 접근
@@ -126,8 +124,6 @@ model Board {
description String? description String?
sortOrder Int @default(0) sortOrder Int @default(0)
status BoardStatus @default(active) status BoardStatus @default(active)
type BoardType @default(general) // 일반/특수
requiresApproval Boolean @default(false) // 게시물 승인 필요 여부
allowAnonymousPost Boolean @default(false) // 익명 글 허용 allowAnonymousPost Boolean @default(false) // 익명 글 허용
allowSecretComment Boolean @default(false) // 비밀댓글 허용 allowSecretComment Boolean @default(false) // 비밀댓글 허용
isAdultOnly Boolean @default(false) // 성인 인증 필요 여부 isAdultOnly Boolean @default(false) // 성인 인증 필요 여부
@@ -154,7 +150,6 @@ model Board {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([status, sortOrder]) @@index([status, sortOrder])
@@index([type, requiresApproval])
@@index([categoryId]) @@index([categoryId])
@@index([mainPageViewTypeId]) @@index([mainPageViewTypeId])
@@index([listViewTypeId]) @@index([listViewTypeId])
@@ -222,6 +217,7 @@ 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])
@@ -230,13 +226,17 @@ model Post {
// 사용자 // 사용자
model User { model User {
userId String @id @default(cuid()) userId String @id
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)
points Int @default(0)
level Int @default(1)
grade Int @default(0)
status UserStatus @default(active) status UserStatus @default(active)
authLevel AuthLevel @default(USER) authLevel AuthLevel @default(USER)
@@ -260,6 +260,7 @@ 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")
@@ -325,6 +326,8 @@ 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)
@@ -333,11 +336,14 @@ model Comment {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
post Post @relation(fields: [postId], references: [id], onDelete: Cascade) post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull) parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
reports Report[] replies Comment[] @relation("CommentReplies")
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
reports Report[]
@@index([postId, createdAt]) @@index([postId, createdAt])
@@index([parentId])
@@map("comments") @@map("comments")
} }
@@ -433,6 +439,21 @@ 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())
@@ -502,6 +523,20 @@ 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())
@@ -670,16 +705,36 @@ 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?
imageUrl String?
sortOrder Int @default(0)
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])
@@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())
@@ -722,6 +777,10 @@ 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?
@@ -740,3 +799,6 @@ model Setting {
@@map("settings") @@map("settings")
} }
// 메인 노출용 제휴 샵 가로 스크롤 데이터
// PartnerShop 모델 제거됨 (PartnerRequest로 통합)

View File

@@ -1,17 +1,185 @@
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) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function randomDate(startYear, endYear) {
const start = new Date(`${startYear}-01-01`).getTime();
const end = new Date(`${endYear}-12-31`).getTime();
return new Date(randomInt(start, end));
}
function generateRandomKoreanName() {
const lastNames = ["김", "이", "박", "최", "정", "강", "조", "윤", "장", "임"];
const firstParts = ["민", "서", "도", "현", "지", "아", "윤", "준", "하", "유", "채", "은", "수", "태", "나"];
const secondParts = ["우", "영", "민", "서", "진", "현", "빈", "율", "솔", "연", "환", "호", "린", "훈", "경"];
const last = lastNames[randomInt(0, lastNames.length - 1)];
const first = firstParts[randomInt(0, firstParts.length - 1)] + secondParts[randomInt(0, secondParts.length - 1)];
return last + first;
}
function generateUniquePhone(i) {
const mid = String(2000 + (i % 8000)).padStart(4, "0");
const last = String(3000 + i).padStart(4, "0");
return `010-${mid}-${last}`;
}
function generateNickname(i) {
const suffix = Math.random().toString(36).slice(2, 4);
return `user${String(i + 1).padStart(3, "0")}${suffix}`;
}
// 랜덤 제목/문장/이미지 도우미
const TITLE_FRAGMENTS = [
// 아주 짧은 키워드
"공지", "업뎃", "버그", "요청", "후기", "정보", "TIP", "사진", "잡담", "나눔",
"질문", "헬프", "리뷰", "이슈", "주의", "긴급", "정리", "모음", "요약", "스샷",
// 짧은 구문
"오늘의 이슈", "핫 토픽", "소소한 일상", "정보 공유", "꿀팁 모음",
"개발 노트", "버그 리포트", "아이디어 제안", "함께 보아요",
];
const SENTENCES = [
"안녕하세요, 간단히 공유 드립니다.",
"도움이 되셨다면 댓글로 알려주세요.",
"의견이나 질문은 언제든 환영입니다.",
"테스트로 작성된 시드 데이터입니다.",
"참고용 스크린샷을 함께 첨부합니다.",
"관련 경험 있으시면 팁 부탁드려요.",
"문서화가 필요해 간단히 정리했습니다.",
"링크와 자료를 함께 첨부합니다.",
"개선 제안은 자유롭게 남겨주세요.",
"읽어주셔서 감사합니다.",
];
const TITLE_SUBS = [
"지금", "방금", "오늘", "금일", "v2", "2025", "베타", "테스트",
"임시", "간단히", "빠르게", "짧게", "새로", "업데이트", "정리", "공유",
];
const TITLE_EMOJIS = ["🔥", "📌", "✅", "❗", "💡", "🆕", "🔧", "📝", "📷"];
function clampTitle(s, max = 60) {
return s.length <= max ? s : s.slice(0, max).trim();
}
function pick(arr) { return arr[randomInt(0, arr.length - 1)]; }
function coin(p = 0.5) { return Math.random() < p; }
function randomTitle(boardName, index) {
// 다양한 템플릿으로 제목 생성 (짧은 것도, 긴 것도 포함)
const a = pick(TITLE_FRAGMENTS);
const b = pick(TITLE_FRAGMENTS);
const sub = pick(TITLE_SUBS);
const emoji = pick(TITLE_EMOJIS);
const num = (index % 99) + 1;
const templates = [
() => `${a}`,
() => `${a} ${emoji}`,
() => `${a} #${num}`,
() => `${a} ${sub}`,
() => `${a} · ${b}`,
() => `[${a}] ${b}`,
() => `${a}: ${b}`,
() => `${a} ${b} ${emoji}`,
// 가끔만 보드명 포함
() => `${boardName} ${a}`,
() => `${boardName} ${a} · ${b}`,
];
// 짧은 제목 확률을 높이기 위해 템플릿 선택 가중치 없이 랜덤
const title = pick(templates)();
return clampTitle(title, 60);
}
function randomSentence() {
return SENTENCES[randomInt(0, SENTENCES.length - 1)];
}
function randomImageUrl(seedKey, w = 800, h = 450) {
// 외부 랜덤 이미지. 네트워크가 제한되면 /sample.jpg로 대체 가능
const seed = encodeURIComponent(String(seedKey));
return `https://picsum.photos/seed/${seed}/${w}/${h}`;
}
async function createRandomUsers(count = 100) {
const roleUser = await prisma.role.findUnique({ where: { name: "user" } });
// 사용되지 않은 전화번호를 찾는 보조 함수
async function findAvailablePhone(startIndex) {
let offset = 0;
while (true) {
const candidate = generateUniquePhone(startIndex + offset);
const exists = await prisma.user.findUnique({ where: { phone: candidate } });
if (!exists) return candidate;
offset += 1;
}
}
const createdUsers = [];
for (let i = 0; i < count; i++) {
// 고정 ID: user001, user002, ...
const userId = `user${String(i + 1).padStart(3, "0")}`;
const existing = await prisma.user.findUnique({ where: { userId } });
let user = existing;
if (!existing) {
const name = generateRandomKoreanName();
const birth = randomDate(1975, 2005);
const phone = await findAvailablePhone(i + 2); // admin이 0001 사용하므로 겹치지 않도록 오프셋
// 닉네임: 중복 없는 랜덤 한글 생성
let nickname = generateRandomKoreanName();
for (let tries = 0; tries < 10; tries++) {
const dup = await prisma.user.findUnique({ where: { nickname } });
if (!dup) break;
nickname = generateRandomKoreanName();
}
// 그래도 중복이면 희귀 조합 한 번 더 시도
const finalDup = await prisma.user.findUnique({ where: { nickname } });
if (finalDup) {
nickname = generateRandomKoreanName();
}
user = await prisma.user.create({
data: {
userId,
nickname,
name,
birth,
phone,
passwordHash: hashPassword("12341234"),
agreementTermsAt: new Date(),
authLevel: "USER",
isAdultVerified: Math.random() < 0.6,
lastLoginAt: Math.random() < 0.8 ? new Date() : null,
},
});
} else {
// 기존 사용자도 패스워드를 1234로 업데이트
await prisma.user.update({
where: { userId: user.userId },
data: { passwordHash: hashPassword("12341234") },
});
}
if (roleUser && user) {
await prisma.userRole.upsert({
where: { userId_roleId: { userId: user.userId, roleId: roleUser.roleId } },
update: {},
create: { userId: user.userId, roleId: roleUser.roleId },
});
}
if (user) createdUsers.push(user);
}
return createdUsers;
}
async function upsertCategories() { async function upsertCategories() {
// 카테고리 트리 (projectmemo 기준 상위 그룹) // 카테고리 트리 (projectmemo 기준 상위 그룹)
const categories = [ const categories = [
{ name: "암실소문", slug: "main", sortOrder: 1, status: "active" }, { name: "메인", slug: "main", sortOrder: 1, status: "active" },
{ name: "명예의 전당", slug: "hall-of-fame", sortOrder: 2, status: "active" }, { name: "명예의 전당", slug: "hall-of-fame", sortOrder: 2, status: "active" },
{ name: "주변 제휴업체", slug: "nearby-partners", sortOrder: 3, status: "active" }, { name: "소통방", slug: "community", sortOrder: 3, status: "active" },
{ name: "제휴업소 정보", slug: "partner-info", sortOrder: 4, status: "active" },
{ name: "방문후기", slug: "reviews", sortOrder: 5, status: "active" },
{ name: "소통방", slug: "community", sortOrder: 6, status: "active" },
{ name: "광고/제휴", slug: "ads-affiliates", sortOrder: 7, status: "active" },
]; ];
const map = {}; const map = {};
for (const c of categories) { for (const c of categories) {
@@ -84,14 +252,24 @@ 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,
}, },
}); });
@@ -108,34 +286,82 @@ 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 = [
// 일반 // 일반
{ name: "공지사항", slug: "notice", description: "공지", type: "general", sortOrder: 1, writeLevel: "moderator" }, { name: "공지사항", slug: "notice", description: "공지", sortOrder: 1, writeLevel: "moderator" },
{ name: "가입인사", slug: "greetings", description: "가입인사", type: "general", sortOrder: 2 }, { name: "가입인사", slug: "greetings", description: "가입인사", sortOrder: 2 },
{ name: "버그건의", slug: "bug-report", description: "버그/건의", type: "general", sortOrder: 3 }, { name: "버그건의", slug: "bug-report", description: "버그/건의", sortOrder: 3 },
{ name: "이벤트", slug: "event", description: "이벤트", type: "general", sortOrder: 4, requiredTags: { required: ["이벤트"] } }, { name: "이벤트", slug: "event", description: "이벤트", sortOrder: 4, requiredTags: { required: ["이벤트"] } },
{ name: "자유게시판", slug: "free", description: "자유", type: "general", sortOrder: 5 }, { name: "자유게시판", slug: "free", description: "자유", sortOrder: 5 },
{ name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", type: "general", sortOrder: 6 }, { name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", sortOrder: 6 },
{ name: "마사지꿀팁", slug: "tips", description: "", type: "general", sortOrder: 7 }, { name: "익명게시판", slug: "anonymous", description: "익명", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true },
{ name: "익명게시판", slug: "anonymous", description: "익명", type: "general", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true }, { name: "청와대", slug: "blue-house", description: "레벨 제한", sortOrder: 10, readLevel: "member" },
{ name: "관리사찾아요", slug: "find-therapist", description: "구인/구직", type: "general", sortOrder: 9 },
{ name: "청와대", slug: "blue-house", description: "레벨 제한", type: "general", sortOrder: 10, readLevel: "member" },
{ name: "방문후기", slug: "reviews", description: "운영자 승인 후 공개", type: "general", sortOrder: 11, requiresApproval: true, requiredTags: { anyOf: ["업체명", "지역"] } },
// 특수 // 특수
{ name: "출석부", slug: "attendance", description: "데일리 체크인", type: "special", sortOrder: 12 }, { name: "출석부", slug: "attendance", description: "데일리 체크인", sortOrder: 12 },
{ name: "주변 제휴업체", slug: "nearby-partners", description: "위치 기반", type: "special", sortOrder: 13 }, { name: "회원랭킹", slug: "ranking", description: "랭킹", sortOrder: 14 },
{ name: "회원랭킹", slug: "ranking", description: "랭킹", type: "special", sortOrder: 14 }, { name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", sortOrder: 15 },
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", type: "special", sortOrder: 15 }, { name: "월간집계", slug: "monthly-stats", description: "월간 통계", sortOrder: 16 },
{ name: "월간집계", slug: "monthly-stats", description: "월간 통계", type: "special", sortOrder: 16 }, // 광고/제휴 계열 게시판은 제거(메인/명예의전당/소통방 외)
// 제휴업소 일반
{ name: "제휴업소", slug: "partners-photos", description: "사진 전용 게시판", type: "general", sortOrder: 17, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
// 광고/제휴
{ name: "제휴문의", slug: "partner-contact", description: "제휴문의", type: "general", sortOrder: 18, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
{ name: "제휴업소 요청", slug: "partner-req", description: "제휴업소 요청", type: "general", sortOrder: 19, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
]; ];
const created = []; const created = [];
// 텍스트/특수랭킹/특수출석 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨)
const mainText = await prisma.boardViewType.findUnique({ where: { key: "main_text" } });
const listText = await prisma.boardViewType.findUnique({ where: { key: "list_text" } });
const mainSpecial = await prisma.boardViewType.findUnique({ where: { key: "main_special_rank" } });
const listSpecial = await prisma.boardViewType.findUnique({ where: { key: "list_special_rank" } });
const listSpecialAttendance = await prisma.boardViewType.findUnique({ where: { key: "list_special_attendance" } });
for (const b of boards) { for (const b of boards) {
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리) // 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
const mapBySlug = { const mapBySlug = {
@@ -149,22 +375,12 @@ async function upsertBoards(admin, categoryMap) {
ranking: "hall-of-fame", ranking: "hall-of-fame",
"free-coupons": "hall-of-fame", "free-coupons": "hall-of-fame",
"monthly-stats": "hall-of-fame", "monthly-stats": "hall-of-fame",
// 주변 제휴업체
"nearby-partners": "nearby-partners",
// 제휴업소 정보
"partners-photos": "partner-info",
// 방문후기
reviews: "reviews",
// 소통방(기본값 community로 처리) // 소통방(기본값 community로 처리)
free: "community", free: "community",
qna: "community", qna: "community",
tips: "community", tips: "community",
anonymous: "community", anonymous: "community",
"find-therapist": "community",
"blue-house": "community",
// 광고/제휴 // 광고/제휴
"partner-contact": "ads-affiliates",
"partner-req": "ads-affiliates",
}; };
const categorySlug = mapBySlug[b.slug] || "community"; const categorySlug = mapBySlug[b.slug] || "community";
const category = categoryMap[categorySlug]; const category = categoryMap[categorySlug];
@@ -173,22 +389,30 @@ async function upsertBoards(admin, categoryMap) {
update: { update: {
description: b.description, description: b.description,
sortOrder: b.sortOrder, sortOrder: b.sortOrder,
type: b.type,
requiresApproval: !!b.requiresApproval,
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" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
}, },
create: { create: {
name: b.name, name: b.name,
slug: b.slug, slug: b.slug,
description: b.description, description: b.description,
sortOrder: b.sortOrder, sortOrder: b.sortOrder,
type: b.type,
requiresApproval: !!b.requiresApproval,
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" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
}, },
}); });
created.push(board); created.push(board);
@@ -209,16 +433,15 @@ 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({
@@ -229,6 +452,42 @@ async function upsertViewTypes() {
} }
} }
async function removeNonPrimaryBoards() {
// 메인/명예의전당/소통방에 해당하지 않는 게시판(광고/제휴 계열) 정리
await prisma.board.deleteMany({
where: { slug: { in: ["partners-photos", "partner-contact", "partner-req"] } },
});
}
async function createPostsForAllBoards(boards, countPerBoard = 100, admin) {
const users = await prisma.user.findMany({ select: { userId: true } });
const userIds = users.map((u) => u.userId);
for (const board of boards) {
// 회원랭킹 보드는 특수랭킹용이라 게시글을 시드하지 않습니다.
if (board.slug === "ranking") continue;
const data = [];
for (let i = 0; i < countPerBoard; i++) {
const authorId = ["notice", "bug-report"].includes(board.slug)
? admin.userId
: userIds[randomInt(0, userIds.length - 1)];
const title = randomTitle(board.name, i);
const img = randomImageUrl(`${board.slug}-${i}`);
const p1 = randomSentence();
const p2 = randomSentence();
const p3 = randomSentence();
data.push({
boardId: board.id,
authorId,
title,
content: `<p>${p1}</p>\n<figure><img src="${img}" alt="seed image" /></figure>\n<p>${p2}</p>\n<p>${p3}</p>`,
status: "published",
isAnonymous: board.allowAnonymousPost ? Math.random() < 0.3 : false,
});
}
await prisma.post.createMany({ data });
}
}
async function seedPolicies() { async function seedPolicies() {
// 금칙어 예시 // 금칙어 예시
const banned = [ const banned = [
@@ -259,41 +518,160 @@ async function seedPolicies() {
} }
} }
async function seedPartnerShops() {
const items = [
{ region: "경기도", name: "test1", address: "수원시 팔달구 매산로 45", imageUrl: "/sample.jpg", sortOrder: 1 },
{ region: "강원도", name: "test2", address: "춘천시 중앙로 112", imageUrl: "/sample.jpg", sortOrder: 2 },
];
for (const it of items) {
await prisma.partnerRequest.upsert({
where: { id: `${it.region}-${it.name}` },
update: { address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true, status: "approved" },
create: { id: `${it.region}-${it.name}`, region: it.region, name: it.name, address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true, status: "approved", category: "misc", latitude: 37.5, longitude: 127.0 },
});
}
// 표시 토글 기본값 보장
const SETTINGS_KEY = "mainpage_settings";
const setting = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
const current = setting ? JSON.parse(setting.value) : {};
const next = { showPartnerShops: true, ...current };
await prisma.setting.upsert({
where: { key: SETTINGS_KEY },
update: { value: JSON.stringify(next) },
create: { key: SETTINGS_KEY, value: JSON.stringify(next) },
});
}
async function seedBanners() {
// 기존 배너 정리 후 5개만 채움
await prisma.banner.deleteMany({});
const items = Array.from({ length: 5 }).map((_, i) => ({
title: `메인 배너 ${i + 1}`,
imageUrl: "/sample.jpg",
linkUrl: "/",
sortOrder: i + 1,
active: true,
}));
await prisma.banner.createMany({ data: items });
}
async function seedMainpageVisibleBoards(boards) {
const SETTINGS_KEY = "mainpage_settings";
const setting = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
const current = setting ? JSON.parse(setting.value) : {};
const wantSlugs = new Set(["notice", "free", "ranking"]);
const visibleBoardIds = boards.filter((b) => wantSlugs.has(b.slug)).map((b) => b.id);
const next = { ...current, visibleBoardIds };
await prisma.setting.upsert({
where: { key: SETTINGS_KEY },
update: { value: JSON.stringify(next) },
create: { key: SETTINGS_KEY, value: JSON.stringify(next) },
});
}
async function main() { 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 seedRandomAttendanceForUsers(randomUsers, 10, 20);
await removeNonPrimaryBoards();
const boards = await upsertBoards(admin, categoryMap); const boards = await upsertBoards(admin, categoryMap);
await seedAdminAttendance(admin);
// 샘플 글 하나 await seedMainpageVisibleBoards(boards);
const free = boards.find((b) => b.slug === "free") || boards[0]; await createPostsForAllBoards(boards, 100, admin);
const post = await prisma.post.create({ await seedPartnerShops();
data: { await seedBanners();
boardId: free.id,
authorId: admin.userId,
title: "첫 글",
content: "메시지 앱 초기 설정 완료",
status: "published",
},
});
await prisma.comment.createMany({
data: [
{ postId: post.id, authorId: admin.userId, content: "환영합니다!" },
{ postId: post.id, authorId: admin.userId, content: "댓글 테스트" },
],
});
await seedPolicies(); await seedPolicies();
// 제휴업체 예시 데이터 // 제휴업체 예시 데이터
const partners = [ const partners = [
{ name: "힐링마사지", category: "spa", latitude: 37.5665, longitude: 126.9780, address: "서울 구" }, { name: "test", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
{ name: "웰빙테라피", 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) {
await prisma.partner.upsert({ where: { name: p.name }, update: p, create: p }); const categoryRef = p.category ? partnerCategoryMap[p.category] : null;
const data = { ...p, categoryId: categoryRef ? categoryRef.id : null };
await prisma.partner.upsert({ where: { name: p.name }, update: data, create: data });
} }
} }

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 829 KiB

After

Width:  |  Height:  |  Size: 836 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 99 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 69 KiB

9
public/svgs/03_gold.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 70 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 92 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 95 KiB

9
public/svgs/08_god.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View File

@@ -4,18 +4,18 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
const navItems = [ const navItems = [
{ href: "/admin/menus", label: "메뉴 관리" }, { href: "/admin/mainpage-settings", label: "메인페이지 설정" },
{ href: "/admin/boards", label: "게시판" }, { href: "/admin/boards", label: "게시판" },
{ href: "/admin/banners", label: "배너" },
{ href: "/admin/partners", label: "제휴업체" },
{ href: "/admin/users", label: "사용자" }, { href: "/admin/users", label: "사용자" },
{ href: "/admin/logs", label: "로그" }, { href: "/admin/logs", label: "로그" },
{ href: "/admin/banners", label: "배너" },
{ href: "/admin/mainpage-settings", label: "메인페이지 설정" },
]; ];
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/80 backdrop-blur h-full"> <aside className="w-56 shrink-0 border-r border-neutral-200 bg-white/90 backdrop-blur h-full">
<div className="px-4 py-4 border-b border-neutral-200"> <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>

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import useSWR from "swr"; import useSWR from "swr";
import { useState } from "react"; import { useRef, useState } from "react";
import { Modal } from "@/app/components/ui/Modal";
const fetcher = (url: string) => fetch(url).then((r) => r.json()); const fetcher = (url: string) => fetch(url).then((r) => r.json());
@@ -8,21 +9,112 @@ export default function AdminBannersPage() {
const { data, mutate } = useSWR<{ banners: any[] }>("/api/admin/banners", fetcher); const { data, mutate } = useSWR<{ banners: any[] }>("/api/admin/banners", fetcher);
const banners = data?.banners ?? []; const banners = data?.banners ?? [];
const [form, setForm] = useState({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 }); const [form, setForm] = useState({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 });
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
async function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
const inputEl = e.currentTarget; // 이벤트 풀링 대비 사본 보관
const file = inputEl.files?.[0];
if (!file) return;
try {
setUploading(true);
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/api/uploads", { method: "POST", body: fd });
const json = await r.json();
if (!r.ok) throw new Error(json?.error || "upload_failed");
setForm((f) => ({ ...f, imageUrl: json.url }));
} catch (err) {
console.error(err);
alert("이미지 업로드 중 오류가 발생했습니다.");
} finally {
setUploading(false);
if (inputEl) inputEl.value = "";
}
}
async function create() { async function create() {
const r = await fetch("/api/admin/banners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(form) }); const r = await fetch("/api/admin/banners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(form) });
if (r.ok) { setForm({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 }); mutate(); } if (r.ok) { setForm({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 }); mutate(); setShowCreateModal(false); }
} }
return ( return (
<div> <div>
<h1> </h1> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}> <h1> </h1>
<input placeholder="제목" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} /> <button
<input placeholder="이미지 URL" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} /> type="button"
<input placeholder="링크 URL(선택)" value={form.linkUrl} onChange={(e) => setForm({ ...form, linkUrl: e.target.value })} /> onClick={() => setShowCreateModal(true)}
<label><input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} /> </label> 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"
<input type="number" placeholder="정렬" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} style={{ width: 80 }} /> >
<button onClick={create}></button>
</button>
</div> </div>
<Modal open={showCreateModal} onClose={() => setShowCreateModal(false)}>
<div className="w-[720px] max-w-[90vw]">
<div className="p-6">
<h2 className="text-lg font-bold mb-4"> </h2>
{(() => {
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}> URL()</label>
<input style={inputStyle} value={form.linkUrl} onChange={(e) => setForm({ ...form, linkUrl: e.target.value })} />
</div>
<div style={{ gridColumn: "span 6" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}> URL</label>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input style={inputStyle} value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
>
</button>
<input ref={fileInputRef} type="file" accept="image/*" onChange={onSelectFile} style={{ display: "none" }} />
{uploading && <span style={{ fontSize: 12, opacity: .7 }}> ...</span>}
</div>
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<label className="inline-flex items-center gap-2 text-sm"><input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} /> </label>
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input type="number" style={inputStyle} value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} />
</div>
{form.imageUrl && (
<div style={{ gridColumn: "span 6" }}>
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}></div>
<img src={form.imageUrl} alt="미리보기" style={{ width: 320, height: 160, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
</div>
)}
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
<button
onClick={create}
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"
>
</button>
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
</div>
</div>
);
})()}
</div>
</div>
</Modal>
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}> <ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{banners.map((b) => ( {banners.map((b) => (
<li key={b.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "flex", gap: 12, alignItems: "center" }}> <li key={b.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "flex", gap: 12, alignItems: "center" }}>
@@ -31,8 +123,8 @@ export default function AdminBannersPage() {
<div><strong>{b.title}</strong> {b.linkUrl && <a style={{ marginLeft: 8 }} href={b.linkUrl}></a>}</div> <div><strong>{b.title}</strong> {b.linkUrl && <a style={{ marginLeft: 8 }} href={b.linkUrl}></a>}</div>
<div style={{ fontSize: 12, opacity: 0.7 }}> {b.sortOrder} · {b.active ? "활성" : "비활성"}</div> <div style={{ fontSize: 12, opacity: 0.7 }}> {b.sortOrder} · {b.active ? "활성" : "비활성"}</div>
</div> </div>
<button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: !b.active }) }); mutate(); }}>{b.active ? "비활성" : "활성"}</button> <button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: !b.active }) }); mutate(); }} className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition">{b.active ? "비활성" : "활성"}</button>
<button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "DELETE" }); mutate(); }}></button> <button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "DELETE" }); mutate(); }} className="h-8 px-3 rounded-md border border-red-200 text-red-600 text-sm cursor-pointer hover:bg-red-100 hover:border-red-300 hover:text-red-700 active:translate-y-px transition"></button>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -33,6 +33,8 @@ 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));
@@ -87,6 +89,21 @@ export default function AdminBoardsPage() {
mutateBoards(); mutateBoards();
} }
// 버튼으로 카테고리 순서 이동 (↑/↓)
function moveCategory(catId: string, delta: number) {
const baseIds = (catOrder.length ? catOrder : categories.map((c: any) => c.id));
const idx = baseIds.indexOf(catId);
if (idx === -1) return;
const to = idx + delta;
if (to < 0 || to >= baseIds.length) return;
const nextIds = [...baseIds];
const [moved] = nextIds.splice(idx, 1);
nextIds.splice(to, 0, moved);
setCatOrder(nextIds);
const nextCats = nextIds.map((id) => categories.find((c: any) => c.id === id)).filter(Boolean) as any[];
reorderCategories(nextCats);
}
// DnD: 카테고리 순서 변경 (저장 시 반영) // DnD: 카테고리 순서 변경 (저장 시 반영)
function reorderCategories(next: any[]) { function reorderCategories(next: any[]) {
setDirtyCats((prev) => { setDirtyCats((prev) => {
@@ -222,7 +239,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: defaultMainTypeId, listViewTypeId: defaultListTypeId }) }); await fetch(`/api/admin/boards`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, categoryId: catId === "uncat" ? null : catId, sortOrder, status: "active", mainPageViewTypeId: textMainTypeId, listViewTypeId: textListTypeId }) });
await mutateBoards(); await mutateBoards();
} }
@@ -275,56 +292,9 @@ export default function AdminBoardsPage() {
onClick={createCategory} onClick={createCategory}
> </button> > </button>
</div> </div>
<ul <ul className="divide-y-2 divide-neutral-100">
className="divide-y-2 divide-neutral-100"
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (draggingCatIndex === null) return;
const ids = (catOrder.length ? catOrder : categories.map((c: any) => c.id));
// 현재 마우스 Y에 해당하는 행 인덱스 계산
let overIdx = -1;
for (let i = 0; i < ids.length; i++) {
const el = catRefs.current[ids[i]];
if (!el) continue;
const rect = el.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
if (e.clientY < mid) { overIdx = i; break; }
}
if (overIdx === -1) overIdx = ids.length - 1;
if (overIdx === draggingCatIndex) return;
setCatOrder((order) => {
const base = order.length ? order : categories.map((c: any) => c.id);
const next = [...base];
const [moved] = next.splice(draggingCatIndex, 1);
next.splice(overIdx, 0, moved);
setDraggingCatIndex(overIdx);
const nextCats = next.map((id) => categories.find((c: any) => c.id === id)).filter(Boolean) as any[];
reorderCategories(nextCats);
return next;
});
}}
onDragEnter={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
onDrop={(e) => {
e.preventDefault();
const ids = (catOrder.length ? catOrder : categories.map((c: any) => c.id));
const nextCats = ids.map((id) => categories.find((c: any) => c.id === id)).filter(Boolean) as any[];
reorderCategories(nextCats);
setDraggingCatIndex(null);
}}
>
{groups.map((g, idx) => ( {groups.map((g, idx) => (
<li <li key={g.id} className="select-none">
key={g.id}
className="select-none"
ref={(el) => { catRefs.current[g.id] = el; }}
onDrop={(e) => {
e.preventDefault();
setDraggingCatIndex(null);
}}
onDragEnd={() => { setDraggingCatIndex(null); }}
>
<div className="px-4 py-3 flex items-center gap-3"> <div className="px-4 py-3 flex items-center gap-3">
<div className="w-8 text-sm text-neutral-500 select-none">{idx + 1}</div> <div className="w-8 text-sm text-neutral-500 select-none">{idx + 1}</div>
{g.id === 'uncat' ? ( {g.id === 'uncat' ? (
@@ -333,9 +303,25 @@ export default function AdminBoardsPage() {
<div className="text-sm font-medium text-neutral-800"> ( )</div> <div className="text-sm font-medium text-neutral-800"> ( )</div>
</div> </div>
) : ( ) : (
<CategoryHeaderContent g={g} onDirty={(payload) => markCatDirty(g.id, { ...payload })} onDragStart={() => { <CategoryHeaderContent g={g} onDirty={(payload) => markCatDirty(g.id, { ...payload })} />
setDraggingCatIndex(idx); )}
}} /> {g.id !== 'uncat' && (
<div className="flex items-center gap-1 ml-2">
<button
type="button"
className="h-7 w-7 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
aria-label="대분류 위로"
disabled={(catOrder.length ? catOrder.indexOf(g.id) : categories.map((c:any)=>c.id).indexOf(g.id)) === 0}
onClick={() => moveCategory(g.id, -1)}
></button>
<button
type="button"
className="h-7 w-7 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
aria-label="대분류 아래로"
disabled={(catOrder.length ? catOrder.indexOf(g.id) : categories.map((c:any)=>c.id).indexOf(g.id)) === ( (catOrder.length ? catOrder.length : categories.length) - 1)}
onClick={() => moveCategory(g.id, 1)}
></button>
</div>
)} )}
<button <button
type="button" type="button"
@@ -374,8 +360,8 @@ export default function AdminBoardsPage() {
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead className="text-xs text-neutral-500 border-b border-neutral-200"> <thead className="text-xs text-neutral-500 border-b border-neutral-200">
<tr> <tr>
<th className="px-2 py-2 w-8"></th>
<th className="px-2 py-2 w-8 text-center">#</th> <th className="px-2 py-2 w-8 text-center">#</th>
<th className="px-2 py-2 w-16 text-center"></th>
<th className="px-3 py-2 text-left"></th> <th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left">slug</th> <th className="px-3 py-2 text-left">slug</th>
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
@@ -384,58 +370,32 @@ export default function AdminBoardsPage() {
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
<th className="px-3 py-2"> </th> <th className="px-3 py-2"> </th>
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
</tr> </tr>
</thead> </thead>
<tbody <tbody className="divide-y divide-neutral-100">
className="divide-y divide-neutral-100"
onDragOver={(e) => {
e.preventDefault();
if (!draggingBoard || draggingBoard.catId !== g.id) return;
const ids = (boardOrderByCat[g.id]?.length ? boardOrderByCat[g.id] : g.items.map((x: any) => x.id));
let overIdx = -1;
for (let i = 0; i < ids.length; i++) {
const el = boardRefs.current[ids[i]];
if (!el) continue;
const rect = el.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
if (e.clientY < mid) { overIdx = i; break; }
}
if (overIdx === -1) overIdx = ids.length - 1;
if (overIdx === draggingBoard.index) return;
setBoardOrderByCat((prev) => {
const base = (prev[g.id]?.length ? prev[g.id] : ids);
const next = [...base];
const [moved] = next.splice(draggingBoard.index, 1);
next.splice(overIdx, 0, moved);
setDraggingBoard({ catId: g.id, index: overIdx });
const nextItems = next.map((id) => g.items.find((x: any) => x.id === id)).filter(Boolean) as any[];
reorderBoards(g.id, nextItems);
return { ...prev, [g.id]: next };
});
}}
onDrop={(e) => {
e.preventDefault();
const ids = (boardOrderByCat[g.id]?.length ? boardOrderByCat[g.id] : g.items.map((x: any) => x.id));
const nextItems = ids.map((id) => g.items.find((x: any) => x.id === id)).filter(Boolean) as any[];
reorderBoards(g.id, nextItems);
setDraggingBoard(null);
}}
>
{g.items.map((b, i) => ( {g.items.map((b, i) => (
<DraggableRow <DraggableRow
key={b.id} key={b.id}
catId={g.id} catId={g.id}
boardId={b.id} boardId={b.id}
index={i} index={i}
setRef={(el) => { boardRefs.current[b.id] = el; }} totalCount={g.items.length}
onStart={() => setDraggingBoard({ catId: g.id, index: i })} onMoveIndex={(delta) => {
onEnd={() => setDraggingBoard(null)} const ids = (boardOrderByCat[g.id]?.length ? boardOrderByCat[g.id] : g.items.map((x: any) => x.id));
const from = i;
const to = from + delta;
if (to < 0 || to >= ids.length) return;
const next = [...ids];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
setBoardOrderByCat((prev) => ({ ...prev, [g.id]: next }));
const nextItems = next.map((id) => g.items.find((x: any) => x.id === id)).filter(Boolean) as any[];
reorderBoards(g.id, nextItems);
}}
> >
<BoardRowCells <BoardRowCells
b={b} b={b}
@@ -478,12 +438,20 @@ export default function AdminBoardsPage() {
function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, mainTypes, listTypes, onAddType, defaultMainTypeId, defaultListTypeId }: { b: any; onDirty: (id: string, draft: any) => void; onDelete: () => void; allowMove?: boolean; categories?: any[]; onMove?: (toId: string) => void; mainTypes?: any[]; listTypes?: any[]; onAddType?: (scope: 'main'|'list') => Promise<string | null>; defaultMainTypeId?: string | null; defaultListTypeId?: string | null }) { 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 effectiveListTypeId = edit.listViewTypeId ?? defaultListTypeId ?? ''; const selectableMainTypes = (mainTypes ?? []).filter((t: any) => t.id !== defaultMainTypeId);
const selectableListTypes = (listTypes ?? []).filter((t: any) => t.id !== defaultListTypeId);
// 표시 값: 현재 값이 선택 가능 목록에 없으면 첫 번째 항목을 사용
const effectiveMainTypeId = selectableMainTypes.some((t: any) => t.id === edit.mainPageViewTypeId)
? edit.mainPageViewTypeId
: (selectableMainTypes[0]?.id ?? '');
const effectiveListTypeId = selectableListTypes.some((t: any) => t.id === edit.listViewTypeId)
? edit.listViewTypeId
: (selectableListTypes[0]?.id ?? '');
return ( return (
<> <>
<td className="px-3 py-2"><input className="h-9 w-full rounded-md border border-neutral-300 px-2 text-sm" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td> <td className="px-3 py-2"><input className="h-9 w-full 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.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td> <td className="px-3 py-2"><input className="h-9 w-full min-w-[200px] rounded-md border border-neutral-300 px-2 text-sm" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center"> <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"
@@ -495,12 +463,11 @@ 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 || null }; const v = { ...edit, mainPageViewTypeId: e.target.value };
setEdit(v); onDirty(b.id, v); setEdit(v); onDirty(b.id, v);
}} }}
> >
<option value="">()</option> {(selectableMainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</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>
@@ -515,12 +482,11 @@ 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 || null }; const v = { ...edit, listViewTypeId: e.target.value };
setEdit(v); onDirty(b.id, v); setEdit(v); onDirty(b.id, v);
}} }}
> >
<option value="">()</option> {(selectableListTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</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>
@@ -542,13 +508,6 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
</td> </td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => { const v = { ...edit, allowAnonymousPost: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td> <td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => { const v = { ...edit, allowAnonymousPost: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowSecretComment} onChange={(e) => { const v = { ...edit, allowSecretComment: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td> <td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowSecretComment} onChange={(e) => { const v = { ...edit, allowSecretComment: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.requiresApproval} onChange={(e) => { const v = { ...edit, requiresApproval: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center">
<select className="h-9 rounded-md border border-neutral-300 px-2 text-sm" value={edit.type} onChange={(e) => { const v = { ...edit, type: e.target.value }; setEdit(v); onDirty(b.id, v); }}>
<option value="general">general</option>
<option value="special">special</option>
</select>
</td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => { const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td> <td className="px-3 py-2 text-center"><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => { const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
{allowMove && categories && onMove ? ( {allowMove && categories && onMove ? (
<td className="px-3 py-2 text-center"> <td className="px-3 py-2 text-center">
@@ -568,44 +527,40 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
); );
} }
function DraggableRow({ catId, boardId, index, children, setRef, onStart, onEnd }: { catId: string; boardId: string; index: number; children: React.ReactNode; setRef: (el: HTMLTableRowElement | null) => void; onStart: () => void; onEnd: () => void }) { function DraggableRow({ catId, boardId, index, totalCount, children, onMoveIndex }: { catId: string; boardId: string; index: number; totalCount: number; children: React.ReactNode; onMoveIndex: (delta: number) => void }) {
return ( return (
<tr ref={setRef} className="align-middle select-none"> <tr className="align-middle select-none">
<td
className="px-2 py-2 w-8 text-xl text-neutral-500 cursor-grab"
title="드래그하여 순서 변경"
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", String(index));
e.dataTransfer.effectAllowed = 'move';
const img = document.createElement('canvas'); img.width = 1; img.height = 1; e.dataTransfer.setDragImage(img, 0, 0);
onStart();
}}
onDragEnd={() => { onEnd(); }}
></td>
<td className="px-2 py-2 w-8 text-center text-neutral-500">{index + 1}</td> <td className="px-2 py-2 w-8 text-center text-neutral-500">{index + 1}</td>
<td className="px-2 py-2 w-16 text-center">
<div className="inline-flex items-center gap-1">
<button
type="button"
className="h-6 w-6 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
aria-label="위로"
onClick={() => onMoveIndex(-1)}
disabled={index === 0}
></button>
<button
type="button"
className="h-6 w-6 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
aria-label="아래로"
onClick={() => onMoveIndex(1)}
disabled={index === (totalCount - 1)}
></button>
</div>
</td>
{children} {children}
</tr> </tr>
); );
} }
function CategoryHeaderContent({ g, onDirty, onDragStart }: { g: any; onDirty: (payload: any) => void; onDragStart?: () => void }) { function CategoryHeaderContent({ g, onDirty }: { g: any; onDirty: (payload: any) => void }) {
const [edit, setEdit] = useState({ name: g.name, slug: g.slug }); const [edit, setEdit] = useState({ name: g.name, slug: g.slug });
return ( return (
<> <>
<div <div className="w-10" />
className="w-10 text-xl text-neutral-500 cursor-grab select-none" <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); }} />
draggable <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); }} />
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", "category-drag");
e.dataTransfer.effectAllowed = 'move';
const img = document.createElement('canvas'); img.width = 1; img.height = 1; e.dataTransfer.setDragImage(img, 0, 0);
onDragStart && onDragStart();
}}
title="드래그하여 순서 변경"
></div>
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} />
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
<div className="flex-1" /> <div className="flex-1" />
</> </>
); );

View File

@@ -1,11 +1,32 @@
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 function AdminLayout({ children }: { children: React.ReactNode }) { export default async function AdminLayout({ children }: { children: React.ReactNode }) {
// 서버에서 쿠키 기반 접근 제어 (미들웨어 보조)
const h = await headers();
const cookieHeader = h.get("cookie") || "";
const uid = cookieHeader
.split(";")
.map((s) => s.trim())
.find((pair) => pair.startsWith("uid="))
?.split("=")[1];
const isAdmin = cookieHeader
.split(";")
.map((s) => s.trim())
.find((pair) => pair.startsWith("isAdmin="))
?.split("=")[1];
if (!uid) {
redirect("/login");
}
if (isAdmin !== "1") {
redirect("/");
}
return ( return (
<div className="min-h-[calc(100vh-0px)] flex"> <div className="min-h-[calc(100vh-0px)] flex">
<AdminSidebar /> <AdminSidebar />

View File

@@ -24,6 +24,17 @@ export default function MainPageSettingsPage() {
visibleBoardIds: settings.visibleBoardIds ?? [], visibleBoardIds: settings.visibleBoardIds ?? [],
}); });
function moveVisibleBoard(index: number, delta: number) {
setDraft((d) => {
const arr = [...(d.visibleBoardIds as string[])];
const nextIndex = index + delta;
if (nextIndex < 0 || nextIndex >= arr.length) return d;
const [item] = arr.splice(index, 1);
arr.splice(nextIndex, 0, item);
return { ...d, visibleBoardIds: arr };
});
}
// settings가 로드되면 draft 동기화 // settings가 로드되면 draft 동기화
useEffect(() => { useEffect(() => {
if (settingsResp?.settings) { if (settingsResp?.settings) {
@@ -110,7 +121,7 @@ export default function MainPageSettingsPage() {
{draft.visibleBoardIds.length === 0 ? ( {draft.visibleBoardIds.length === 0 ? (
<p className="text-sm text-neutral-400 py-4 text-center"> .</p> <p className="text-sm text-neutral-400 py-4 text-center"> .</p>
) : ( ) : (
draft.visibleBoardIds.map((boardId: string) => { draft.visibleBoardIds.map((boardId: string, idx: number) => {
const board = boards.find((b: any) => b.id === boardId); const board = boards.find((b: any) => b.id === boardId);
if (!board) return null; if (!board) return null;
const category = categories.find((c: any) => c.id === board.categoryId); const category = categories.find((c: any) => c.id === board.categoryId);
@@ -122,15 +133,37 @@ export default function MainPageSettingsPage() {
<span className="text-xs text-neutral-500">({category.name})</span> <span className="text-xs text-neutral-500">({category.name})</span>
)} )}
</div> </div>
<button <div className="flex items-center gap-2">
type="button" <div className="flex items-center gap-1">
onClick={() => { <button
setDraft({ ...draft, visibleBoardIds: draft.visibleBoardIds.filter((id: string) => id !== boardId) }); type="button"
}} onClick={() => moveVisibleBoard(idx, -1)}
className="text-xs text-red-600 hover:text-red-800" disabled={idx === 0}
> className="h-7 px-2 rounded-md border border-neutral-300 text-xs disabled:opacity-40"
aria-label="위로"
</button> >
</button>
<button
type="button"
onClick={() => moveVisibleBoard(idx, 1)}
disabled={idx === (draft.visibleBoardIds.length - 1)}
className="h-7 px-2 rounded-md border border-neutral-300 text-xs disabled:opacity-40"
aria-label="아래로"
>
</button>
</div>
<button
type="button"
onClick={() => {
setDraft({ ...draft, visibleBoardIds: draft.visibleBoardIds.filter((id: string) => id !== boardId) });
}}
className="h-7 px-3 rounded-md border border-red-200 text-xs text-red-600 hover:bg-red-50"
>
</button>
</div>
</div> </div>
); );
}) })

View File

@@ -1,82 +0,0 @@
"use client";
import { useState } from "react";
type MenuItem = { id: string; label: string; path: string; visible: boolean; order: number };
const initialMenus: MenuItem[] = [
{ id: "1", label: "홈", path: "/", visible: true, order: 1 },
{ id: "2", label: "게시판", path: "/boards", visible: true, order: 2 },
{ id: "3", label: "쿠폰", path: "/coupons", visible: false, order: 3 },
];
export default function AdminMenusPage() {
const [menus, setMenus] = useState<MenuItem[]>(initialMenus);
const [form, setForm] = useState<{ label: string; path: string; visible: boolean }>({ label: "", path: "", visible: true });
function addMenu() {
if (!form.label.trim() || !form.path.trim()) return;
const next: MenuItem = { id: crypto.randomUUID(), label: form.label, path: form.path, visible: form.visible, order: menus.length + 1 };
setMenus((m) => [...m, next]);
setForm({ label: "", path: "", visible: true });
}
function removeMenu(id: string) {
setMenus((m) => m.filter((x) => x.id !== id));
}
function toggleVisible(id: string) {
setMenus((m) => m.map((x) => (x.id === id ? { ...x, visible: !x.visible } : x)));
}
return (
<div className="space-y-6">
<header className="flex items-center justify-between">
<h1 className="text-xl md:text-2xl font-bold text-neutral-900"> </h1>
</header>
{/* 추가 폼 */}
<section className="rounded-xl bg-white border border-neutral-200 overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 text-sm font-medium"> </div>
<div className="p-4 grid grid-cols-1 md:grid-cols-[240px_1fr_auto] gap-3 items-center">
<input className="h-10 rounded-md border border-neutral-300 px-3 text-sm" placeholder="이름" value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} />
<input className="h-10 rounded-md border border-neutral-300 px-3 text-sm" placeholder="경로 (/path)" value={form.path} onChange={(e) => setForm({ ...form, path: e.target.value })} />
<div className="flex items-center gap-3">
<label className="flex items-center gap-1 text-sm text-neutral-700">
<input type="checkbox" checked={form.visible} onChange={(e) => setForm({ ...form, visible: e.target.checked })} />
</label>
<button className="h-10 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800" onClick={addMenu}></button>
</div>
</div>
</section>
{/* 목록 */}
<section className="rounded-xl bg-white border border-neutral-200 overflow-hidden">
<div className="px-4 py-2 text-xs text-neutral-500 border-b border-neutral-200 grid grid-cols-[60px_1fr_1fr_120px_120px]">
<div>#</div>
<div></div>
<div></div>
<div className="text-center"></div>
<div className="text-right"></div>
</div>
<ul className="divide-y divide-neutral-100">
{menus.sort((a, b) => a.order - b.order).map((m, idx) => (
<li key={m.id} className="px-4 py-3 grid grid-cols-[60px_1fr_1fr_120px_120px] items-center">
<div className="text-sm text-neutral-500">{idx + 1}</div>
<div className="truncate text-sm">{m.label}</div>
<div className="truncate text-sm text-neutral-700">{m.path}</div>
<div className="text-center">
<button onClick={() => toggleVisible(m.id)} className={`h-7 px-3 rounded-full text-xs border ${m.visible ? "bg-neutral-900 text-white border-neutral-900" : "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"}`}>{m.visible ? "표시" : "숨김"}</button>
</div>
<div className="text-right">
<button onClick={() => removeMenu(m.id)} className="h-7 px-3 rounded-md border border-neutral-300 text-xs hover:bg-neutral-100"></button>
</div>
</li>
))}
</ul>
</section>
</div>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import useSWR from "swr";
import { useState } from "react";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function AdminPartnerShopsPage() {
const { data, mutate } = useSWR<{ items: any[] }>("/api/admin/partner-shops", fetcher);
const items = data?.items ?? [];
const [form, setForm] = useState({ region: "", name: "", address: "", imageUrl: "", active: true, sortOrder: 0 });
async function create() {
const r = await fetch("/api/admin/partner-shops", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(form) });
if (r.ok) { setForm({ region: "", name: "", address: "", imageUrl: "", active: true, sortOrder: 0 }); mutate(); }
}
return (
<div>
<h1> </h1>
<div style={{ display: "flex", gap: 8, marginBottom: 12, flexWrap: "wrap" }}>
<input placeholder="지역" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} />
<input placeholder="이름" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<input placeholder="주소" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} style={{ width: 320 }} />
<input placeholder="이미지 URL" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} style={{ width: 280 }} />
<label><input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} /> </label>
<input type="number" placeholder="정렬" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} style={{ width: 80 }} />
<button onClick={create}></button>
</div>
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{items.map((it) => (
<li key={it.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "flex", gap: 12, alignItems: "center" }}>
<img src={it.imageUrl} alt={it.name} style={{ width: 80, height: 48, objectFit: "cover", borderRadius: 6 }} />
<div style={{ flex: 1 }}>
<div><strong>{it.name}</strong> <span style={{ marginLeft: 8, fontSize: 12, opacity: .7 }}>{it.region}</span></div>
<div style={{ fontSize: 12, opacity: 0.7 }}>{it.address}</div>
<div style={{ fontSize: 12, opacity: 0.7 }}> {it.sortOrder} · {it.active ? "활성" : "비활성"}</div>
</div>
<button onClick={async () => { await fetch(`/api/admin/partner-shops/${it.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: !it.active }) }); mutate(); }}>{it.active ? "비활성" : "활성"}</button>
<button onClick={async () => { await fetch(`/api/admin/partner-shops/${it.id}`, { method: "DELETE" }); mutate(); }}></button>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,373 @@
"use client";
import useSWR from "swr";
import { useState, useRef } from "react";
import { Modal } from "@/app/components/ui/Modal";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function AdminPartnersPage() {
const { data, mutate } = useSWR<{ partners: any[] }>("/api/admin/partners", fetcher);
const { data: catData, mutate: mutateCategories } = useSWR<{ categories: any[] }>("/api/admin/partner-categories", fetcher);
const partners = data?.partners ?? [];
const categories = catData?.categories ?? [];
const [form, setForm] = useState({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" });
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const editFileInputRef = useRef<HTMLInputElement>(null);
const [editUploading, setEditUploading] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editDraft, setEditDraft] = useState<any>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
async function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
const inputEl = e.currentTarget;
const file = inputEl.files?.[0];
if (!file) return;
try {
setUploading(true);
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/api/uploads", { method: "POST", body: fd });
const json = await r.json();
if (!r.ok) throw new Error(json?.error || "upload_failed");
setForm((f) => ({ ...f, imageUrl: json.url }));
} catch (err) {
console.error(err);
alert("이미지 업로드 중 오류가 발생했습니다.");
} finally {
setUploading(false);
if (inputEl) inputEl.value = "";
}
}
async function onSelectEditFile(e: React.ChangeEvent<HTMLInputElement>) {
const inputEl = e.currentTarget;
const file = inputEl.files?.[0];
if (!file) return;
try {
setEditUploading(true);
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/api/uploads", { method: "POST", body: fd });
const json = await r.json();
if (!r.ok) throw new Error(json?.error || "upload_failed");
setEditDraft((d: any) => ({ ...(d || {}), imageUrl: json.url }));
} catch (err) {
console.error(err);
alert("이미지 업로드 중 오류가 발생했습니다.");
} finally {
setEditUploading(false);
if (inputEl) inputEl.value = "";
}
}
async function create() {
// 필수값 검증: 이름/카테고리/위도/경도
if (!form.name || !form.categoryId || !String(form.latitude).trim() || !String(form.longitude).trim()) {
alert("이름, 카테고리, 위도, 경도를 모두 선택/입력해 주세요.");
return;
}
const lat = Number(form.latitude);
const lon = Number(form.longitude);
if (!isFinite(lat) || !isFinite(lon)) {
alert("위도/경도는 숫자여야 합니다.");
return;
}
const payload = { ...form, latitude: lat, longitude: lon, categoryId: form.categoryId } as any;
const r = await fetch("/api/admin/partners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
if (r.ok) {
setForm({ name: "", latitude: "", longitude: "", address: "", imageUrl: "", categoryId: "" });
mutate();
setShowCreateModal(false);
} else {
let msg = "저장에 실패했습니다.";
try {
const j = await r.json();
msg = j?.message || j?.error || msg;
if (r.status === 409 && j?.error === "duplicate_name") msg = "이미 존재하는 업체명입니다.";
if (r.status === 400) msg = msg || "입력값을 확인해 주세요.";
} catch {}
alert(msg);
}
}
return (
<div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
<h1> </h1>
<button
type="button"
onClick={() => setShowCreateModal(true)}
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"
>
</button>
</div>
<Modal open={showCreateModal} onClose={() => setShowCreateModal(false)}>
<div className="w-[720px] max-w-[90vw]">
<div className="p-6">
<h2 className="text-lg font-bold mb-4"> </h2>
{(() => {
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<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 })}>
<option value="">()</option>
{categories.map((c: any) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={form.latitude} onChange={(e) => setForm({ ...form, latitude: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={form.longitude} onChange={(e) => setForm({ ...form, longitude: e.target.value })} />
</div>
<div style={{ gridColumn: "span 6" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}> URL</label>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input style={inputStyle} value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
>
</button>
<input ref={fileInputRef} type="file" accept="image/*" onChange={onSelectFile} style={{ display: "none" }} />
{uploading && <span style={{ fontSize: 12, opacity: .7 }}> ...</span>}
</div>
</div>
<div style={{ gridColumn: "span 6" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>()</label>
<input style={inputStyle} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</div>
{form.imageUrl && (
<div style={{ gridColumn: "span 6" }}>
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}></div>
<img src={form.imageUrl} alt="미리보기" style={{ width: 240, height: 120, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
</div>
)}
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
<button
onClick={create}
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"
>
</button>
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
</div>
</div>
);
})()}
</div>
</div>
</Modal>
<Modal open={showEditModal} onClose={() => { setShowEditModal(false); setEditingId(null); setEditDraft(null); }}>
<div className="w-[720px] max-w-[90vw]">
<div className="p-6">
<h2 className="text-lg font-bold mb-4"> </h2>
{(() => {
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={editDraft?.name ?? ""} onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<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 })}>
<option value="">()</option>
{categories.map((c: any) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={editDraft?.latitude ?? ""} onChange={(e) => setEditDraft({ ...editDraft, latitude: e.target.value })} />
</div>
<div style={{ gridColumn: "span 3" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={editDraft?.longitude ?? ""} onChange={(e) => setEditDraft({ ...editDraft, longitude: e.target.value })} />
</div>
<div style={{ gridColumn: "span 6" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}> URL</label>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input style={inputStyle} value={editDraft?.imageUrl ?? ""} onChange={(e) => setEditDraft({ ...editDraft, imageUrl: e.target.value })} />
<button
type="button"
onClick={() => editFileInputRef.current?.click()}
disabled={editUploading}
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
>
</button>
<input ref={editFileInputRef} type="file" accept="image/*" onChange={onSelectEditFile} style={{ display: "none" }} />
{editUploading && <span style={{ fontSize: 12, opacity: .7 }}> ...</span>}
</div>
</div>
<div style={{ gridColumn: "span 6" }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}></label>
<input style={inputStyle} value={editDraft?.address ?? ""} onChange={(e) => setEditDraft({ ...editDraft, address: e.target.value })} />
</div>
{editDraft?.imageUrl && (
<div style={{ gridColumn: "span 6" }}>
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}></div>
<img src={editDraft.imageUrl} alt="미리보기" style={{ width: 240, height: 120, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
</div>
)}
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
<button
onClick={async () => {
if (!editingId) return;
const resp = await fetch(`/api/admin/partners/${editingId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...editDraft, categoryId: (editDraft?.categoryId || null) }) });
if (!resp.ok) {
let msg = "저장에 실패했습니다.";
try {
const j = await resp.json();
msg = j?.message || j?.error || msg;
} catch {}
alert(msg);
return;
}
setEditingId(null);
setEditDraft(null);
setShowEditModal(false);
mutate();
}}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
>
</button>
<button
type="button"
onClick={() => { setShowEditModal(false); setEditingId(null); setEditDraft(null); }}
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
</div>
</div>
);
})()}
</div>
</div>
</Modal>
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{partners.map((p) => (
<li key={p.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "grid", gridTemplateColumns: "auto 1fr 1fr 1fr auto", gap: 8, alignItems: "center" }}>
<div style={{ width: 80 }}>
{p.imageUrl ? (
<img src={p.imageUrl} alt={p.name} style={{ width: 80, height: 48, objectFit: "cover", borderRadius: 6 }} />
) : (
<div style={{ width: 80, height: 48, border: "1px solid #eee", borderRadius: 6, background: "#fafafa" }} />
)}
</div>
{false ? (
<></>
) : (
<>
<div>
<div><strong>{p.name}</strong> {p.categoryRef ? <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>[{p.categoryRef.name}]</span> : null}</div>
<div style={{ fontSize: 12, opacity: .7 }}>{p.address || "(주소 없음)"}</div>
</div>
<div style={{ fontSize: 12, opacity: .7 }}> {p.latitude}</div>
<div style={{ fontSize: 12, opacity: .7 }}> {p.longitude}</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button
onClick={() => { setEditingId(p.id); setEditDraft({ name: p.name, categoryId: p.categoryId ?? "", latitude: String(p.latitude), longitude: String(p.longitude), address: p.address ?? "", imageUrl: p.imageUrl ?? "", sortOrder: p.sortOrder ?? 0 }); setShowEditModal(true); }}
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
<button
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "DELETE" }); mutate(); }}
className="h-8 px-3 rounded-md border border-red-200 text-red-600 text-sm cursor-pointer hover:bg-red-100 hover:border-red-300 hover:text-red-700 active:translate-y-px transition"
>
</button>
<button
title="위로"
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: (p.sortOrder ?? 0) - 1 }) }); mutate(); }}
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
<button
title="아래로"
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: (p.sortOrder ?? 0) + 1 }) }); mutate(); }}
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
>
</button>
</div>
</>
)}
</li>
))}
</ul>
<div>
<hr style={{ margin: "24px 0" }} />
<h2 className="text-lg font-bold mb-2"> </h2>
<CategoryManager categories={categories} onChanged={mutateCategories} />
</div>
</div>
);
}
function CategoryManager({ categories, onChanged }: { categories: any[]; onChanged: () => void }) {
const [name, setName] = useState("");
return (
<div className="border border-neutral-200 rounded-lg p-3">
<div className="flex items-center gap-2 mb-3">
<input
placeholder="카테고리 이름"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-9 px-3 rounded-md border border-neutral-300 flex-1"
/>
<button
onClick={async () => { if (!name.trim()) return; const r = await fetch("/api/admin/partner-categories", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: name.trim() }) }); if (r.ok) { setName(""); onChanged(); } else { try { const j = await r.json(); alert(j?.message || j?.error || "생성 실패"); } catch { alert("생성 실패"); } } }}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
>
</button>
</div>
<ul className="flex flex-wrap gap-2">
{categories.map((c: any) => (
<li key={c.id} className="flex items-center gap-2 px-2 py-1 rounded-full border border-neutral-300 text-sm">
<span>{c.name}</span>
<button
title="삭제"
onClick={async () => { const r = await fetch(`/api/admin/partner-categories/${c.id}`, { method: "DELETE" }); if (r.ok) onChanged(); else { try { const j = await r.json(); alert(j?.message || j?.error || "삭제 실패"); } catch { alert("삭제 실패"); } } }}
className="px-2 h-6 rounded-md border border-red-200 text-red-600 hover:bg-red-100 hover:border-red-300 hover:text-red-700"
>
</button>
</li>
))}
</ul>
</div>
);
}

View File

@@ -1,36 +1,81 @@
"use client"; "use client";
import useSWR from "swr"; import useSWR from "swr";
import { useState } from "react"; import { useMemo, useState } from "react";
const fetcher = (url: string) => fetch(url).then((r) => r.json()); const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function AdminUsersPage() { export default function AdminUsersPage() {
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const { data, mutate } = useSWR<{ users: any[] }>(`/api/admin/users?q=${encodeURIComponent(q)}`, fetcher); const [queryDraft, setQueryDraft] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const key = useMemo(() => (
`/api/admin/users?q=${encodeURIComponent(q)}&page=${page}&pageSize=${pageSize}`
),[q, page, pageSize]);
const { data, mutate } = useSWR<{ total: number; page: number; pageSize: number; users: any[] }>(key, fetcher);
const users = data?.users ?? []; const users = data?.users ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const handleSearch = () => {
const term = queryDraft.trim();
if (!term) { setQ(""); setPage(1); return; }
setQ(term);
setPage(1);
};
return ( return (
<div> <div>
<h1> </h1> <h1> </h1>
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}> <div className="mb-3 rounded-xl border border-neutral-300 bg-white p-3 flex items-center gap-2">
<input placeholder="검색(nickname/phone/name)" value={q} onChange={(e) => setQ(e.target.value)} /> <input
className="h-9 w-full max-w-[360px] px-3 rounded-md border border-neutral-300 bg-white text-sm"
placeholder="검색 (닉네임/전화/이름)"
value={queryDraft}
onChange={(e) => setQueryDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
/>
<button
className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100 disabled:opacity-50"
onClick={handleSearch}
disabled={false}
>
</button>
<span className="ml-auto text-xs text-neutral-600"> {total.toLocaleString()}</span>
</div>
<div className="rounded-xl border border-neutral-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[#f6f4f4] border-b border-[#e6e6e6]">
<tr>
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">ID</th>
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]"></th>
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]"></th>
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]"></th>
<th className="px-4 py-2 text-right text-[12px] text-[#8c8c8c]"></th>
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]"></th>
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]"></th>
<th className="px-4 py-2 text-center 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-center text-[12px] text-[#8c8c8c]"></th>
</tr>
</thead>
<tbody className="divide-y divide-[#ececec] bg-white">
{users.map((u) => (
<Row key={u.userId} u={u} onChanged={mutate} />
))}
</tbody>
</table>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 12 }}>
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}></button>
<span style={{ fontSize: 12 }}> {page} / {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}></button>
<span style={{ marginLeft: 12, fontSize: 12 }}> </span>
<select value={pageSize} onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(1); }}>
{[10, 20, 50, 100].map((s) => (<option key={s} value={s}>{s}</option>))}
</select>
</div> </div>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<Row key={u.userId} u={u} onChanged={mutate} />
))}
</tbody>
</table>
</div> </div>
); );
} }
@@ -45,25 +90,31 @@ 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> <tr className="hover:bg-neutral-50">
<td>{u.nickname}</td> <td className="px-4 py-2 text-left tabular-nums">{u.userId}</td>
<td>{u.name}</td> <td className="px-4 py-2 text-left">{u.nickname}</td>
<td>{u.phone}</td> <td className="px-4 py-2 text-left">{u.name}</td>
<td> <td className="px-4 py-2 text-left">{u.phone}</td>
<select value={status} onChange={(e) => setStatus(e.target.value)}> <td className="px-4 py-2 text-right tabular-nums">{(u.points ?? 0).toLocaleString()}</td>
<option value="active">active</option> <td className="px-4 py-2 text-center">{u.level ?? 1}</td>
<option value="suspended">suspended</option> <td className="px-4 py-2 text-center">{u.grade ?? 0}</td>
<option value="withdrawn">withdrawn</option> <td className="px-4 py-2 text-center">
<select className="h-8 px-2 border border-neutral-300 rounded-md bg-white text-sm" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="active"></option>
<option value="suspended"></option>
<option value="withdrawn"></option>
</select> </select>
</td> </td>
<td> <td className="px-4 py-2">
{allRoles.map((r) => ( {allRoles.map((r) => (
<label key={r} style={{ marginRight: 8 }}> <label key={r} className="mr-2">
<input type="checkbox" checked={roles.includes(r)} onChange={(e) => setRoles((prev) => (e.target.checked ? Array.from(new Set([...prev, r])) : prev.filter((x) => x !== r)))} /> {r} <input type="checkbox" checked={roles.includes(r)} onChange={(e) => setRoles((prev) => (e.target.checked ? Array.from(new Set([...prev, r])) : prev.filter((x) => x !== r)))} /> {r}
</label> </label>
))} ))}
</td> </td>
<td><button onClick={save}></button></td> <td className="px-4 py-2 text-center">
<button className="h-8 px-3 rounded-md bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95" onClick={save}></button>
</td>
</tr> </tr>
); );
} }

View File

@@ -1,5 +1,6 @@
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() {
@@ -9,8 +10,11 @@ export async function GET() {
const createSchema = z.object({ const createSchema = z.object({
title: z.string().min(1), title: z.string().min(1),
imageUrl: z.string().url(), // 절대 URL 또는 /로 시작하는 상대경로 허용
linkUrl: z.string().url().optional(), imageUrl: z.string().refine((v) => /^https?:\/\//.test(v) || v.startsWith("/"), {
message: "imageUrl must be absolute URL or start with /",
}),
linkUrl: z.string().refine((v) => !v || /^https?:\/\//.test(v), { message: "linkUrl must be http(s) URL" }).optional(),
active: z.boolean().optional(), active: z.boolean().optional(),
sortOrder: z.coerce.number().int().optional(), sortOrder: z.coerce.number().int().optional(),
startAt: z.coerce.date().optional(), startAt: z.coerce.date().optional(),
@@ -21,7 +25,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 }); const banner = await prisma.banner.create({ data: parsed.data as Prisma.BannerCreateInput });
return NextResponse.json({ banner }, { status: 201 }); return NextResponse.json({ banner }, { status: 201 });
} }

View File

@@ -5,7 +5,7 @@ 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", "slug", "description", "sortOrder", "readLevel", "writeLevel", "allowAnonymousPost", "allowSecretComment", "requiresApproval", "status", "type", "isAdultOnly", "categoryId", "mainPageViewTypeId", "listViewTypeId"]) { for (const k of ["name", "slug", "description", "sortOrder", "readLevel", "writeLevel", "allowAnonymousPost", "allowSecretComment", "status", "isAdultOnly", "categoryId", "mainPageViewTypeId", "listViewTypeId"]) {
if (k in body) data[k] = body[k]; if (k in body) data[k] = body[k];
} }
if ("requiredTags" in body) { if ("requiredTags" in body) {

View File

@@ -1,5 +1,6 @@
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() {
@@ -16,8 +17,6 @@ export async function GET() {
writeLevel: true, writeLevel: true,
allowAnonymousPost: true, allowAnonymousPost: true,
allowSecretComment: true, allowSecretComment: true,
requiresApproval: true,
type: true,
status: true, status: true,
categoryId: true, categoryId: true,
mainPageViewTypeId: true, mainPageViewTypeId: true,
@@ -37,9 +36,7 @@ const createSchema = z.object({
writeLevel: z.string().optional(), writeLevel: z.string().optional(),
allowAnonymousPost: z.boolean().optional(), allowAnonymousPost: z.boolean().optional(),
allowSecretComment: z.boolean().optional(), allowSecretComment: z.boolean().optional(),
requiresApproval: z.boolean().optional(),
status: z.string().optional(), status: z.string().optional(),
type: z.string().optional(),
isAdultOnly: z.boolean().optional(), isAdultOnly: z.boolean().optional(),
categoryId: z.string().nullable().optional(), categoryId: z.string().nullable().optional(),
}); });
@@ -58,7 +55,13 @@ export async function POST(req: Request) {
}); });
sortOrder = (max._max.sortOrder ?? 0) + 1; sortOrder = (max._max.sortOrder ?? 0) + 1;
} }
const created = await prisma.board.create({ data: { ...data, sortOrder } }); const { categoryId, sortOrder: _ignored, ...rest } = data;
const createData: Prisma.BoardCreateInput = {
...(rest as any),
sortOrder,
...(categoryId ? { category: { connect: { id: categoryId } } } : {}),
};
const created = await prisma.board.create({ data: createData });
return NextResponse.json({ board: created }, { status: 201 }); return NextResponse.json({ board: created }, { status: 201 });
} }

View File

@@ -1,6 +1,7 @@
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({
@@ -20,7 +21,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 }); const category = await prisma.boardCategory.create({ data: parsed.data as Prisma.BoardCategoryCreateInput });
return NextResponse.json({ category }, { status: 201 }); return NextResponse.json({ category }, { status: 201 });
} }

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
const body = await req.json().catch(() => ({}));
const data: any = {};
for (const k of ["name", "sortOrder"]) if (k in body) data[k] = body[k];
try {
const category = await prisma.partnerCategory.update({ where: { id }, data });
return NextResponse.json({ category });
} catch (e: any) {
if (e?.code === 'P2002') return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 카테고리입니다.' }, { status: 409 });
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
}
}
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
try {
await prisma.partnerCategory.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch (e: any) {
return NextResponse.json({ error: 'category_in_use', message: '해당 카테고리를 사용하는 제휴업체가 있습니다.' }, { status: 409 });
}
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
export async function GET() {
const categories = await prisma.partnerCategory.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }] });
return NextResponse.json({ categories });
}
const createSchema = z.object({ name: z.string().min(1), sortOrder: z.coerce.number().int().optional() });
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
try {
const category = await prisma.partnerCategory.create({ data: { name: parsed.data.name, sortOrder: parsed.data.sortOrder ?? 0 } });
return NextResponse.json({ category }, { status: 201 });
} catch (e: any) {
if (e?.code === 'P2002') return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 카테고리입니다.' }, { status: 409 });
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,21 @@
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 ["region", "name", "address", "imageUrl", "active", "sortOrder"]) {
if (k in body) data[k] = body[k];
}
const item = await prisma.partnerRequest.update({ where: { id }, data });
return NextResponse.json({ item });
}
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
await prisma.partnerRequest.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
import type { Prisma } from "@prisma/client";
export async function GET() {
const rows = await prisma.partnerRequest.findMany({
where: { status: "approved" },
orderBy: [{ active: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }],
select: { id: true, region: true, name: true, address: true, imageUrl: true, sortOrder: true, active: true },
});
return NextResponse.json({ items: rows });
}
const createSchema = z.object({
region: z.string().min(1),
name: z.string().min(1),
address: z.string().min(1),
imageUrl: z.string().min(1),
sortOrder: z.coerce.number().int().optional(),
active: z.boolean().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 });
// 통합: 승인된 레코드로 등록
const item = await prisma.partnerRequest.create({ data: { ...(parsed.data as any), status: "approved" } });
return NextResponse.json({ item }, { status: 201 });
}

View File

@@ -0,0 +1,41 @@
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", "category", "latitude", "longitude", "address", "imageUrl", "sortOrder", "categoryId"]) {
if (k in body) data[k] = body[k];
}
if (typeof data.latitude !== "undefined") data.latitude = Number(data.latitude);
if (typeof data.longitude !== "undefined") data.longitude = Number(data.longitude);
if (typeof data.sortOrder !== "undefined") data.sortOrder = Number(data.sortOrder);
// categoryId가 들어왔고 category 문자열이 비어있으면 카테고리명으로 채움
if (typeof data.categoryId !== "undefined" && (typeof data.category === "undefined" || data.category === null)) {
if (data.categoryId) {
const cat = await prisma.partnerCategory.findUnique({ where: { id: String(data.categoryId) } });
if (cat) data.category = cat.name;
}
}
try {
const partner = await prisma.partner.update({ where: { id }, data });
return NextResponse.json({ partner });
} catch (e) {
// DB에 sortOrder 컬럼이 아직 없는 환경 대비: 해당 키 제거 후 재시도
if (Object.prototype.hasOwnProperty.call(data, "sortOrder")) {
const { sortOrder, ...rest } = data;
const partner = await prisma.partner.update({ where: { id }, data: rest });
return NextResponse.json({ partner });
}
throw e;
}
}
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
await prisma.partner.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
export async function GET() {
try {
// 카테고리 조인 포함
const partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], include: { categoryRef: true } });
return NextResponse.json({ partners });
} catch (_) {
// 컬럼이 아직 DB에 없으면 createdAt 기준으로 폴백
const partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" }, include: { categoryRef: true } });
return NextResponse.json({ partners });
}
}
const createSchema = z.object({
name: z.string().min(1),
category: z.string().min(1).optional(),
latitude: z.coerce.number(),
longitude: z.coerce.number(),
address: z.string().min(1).optional(),
imageUrl: z
.string()
.refine((v) => !v || /^https?:\/\//.test(v) || v.startsWith("/"), {
message: "imageUrl must be http(s) URL or start with /",
})
.optional(),
sortOrder: z.coerce.number().int().optional(),
categoryId: z.string().min(1),
});
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
try {
const { categoryId } = parsed.data as any;
const cat = await prisma.partnerCategory.findUnique({ where: { id: categoryId } });
if (!cat) return NextResponse.json({ error: 'invalid_category', message: '유효하지 않은 카테고리입니다.' }, { status: 400 });
const data: any = { ...parsed.data };
if (!data.category) data.category = cat.name;
const partner = await prisma.partner.create({ data });
return NextResponse.json({ partner }, { status: 201 });
} catch (e: any) {
// Unique name 에러 처리
if (e?.code === 'P2002') {
return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 업체명입니다.' }, { status: 409 });
}
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
}
}

View File

@@ -4,34 +4,46 @@ 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 q = searchParams.get("q") || ""; const q = searchParams.get("q") || "";
const users = await prisma.user.findMany({ const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
where: q const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10)));
? { const where = q
OR: [ ? {
{ nickname: { contains: q } }, OR: [
{ phone: { contains: q } }, { nickname: { contains: q } },
{ name: { contains: q } }, { phone: { contains: q } },
], { name: { contains: q } },
} ],
: {}, }
orderBy: { createdAt: "desc" }, : {};
select: {
userId: true, const [total, users] = await Promise.all([
nickname: true, prisma.user.count({ where }),
name: true, prisma.user.findMany({
phone: true, where,
status: true, orderBy: { createdAt: "desc" },
authLevel: true, skip: (page - 1) * pageSize,
createdAt: true, take: pageSize,
userRoles: { select: { role: { select: { name: true } } } }, select: {
}, userId: true,
take: 100, nickname: true,
}); name: true,
phone: true,
status: true,
authLevel: true,
createdAt: true,
points: true,
level: true,
grade: true,
userRoles: { select: { role: { select: { name: true } } } },
},
}),
]);
const items = users.map((u) => ({ const items = users.map((u) => ({
...u, ...u,
roles: u.userRoles.map((r) => r.role.name), roles: u.userRoles.map((r) => r.role.name),
})); }));
return NextResponse.json({ users: items }); return NextResponse.json({ total, page, pageSize, users: items });
} }

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