Compare commits
79 Commits
35ef4d1f5b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afc714022f | ||
|
|
c85450ce37 | ||
|
|
c5bc8f5b49 | ||
|
|
14c80baeec | ||
|
|
5287611bf7 | ||
|
|
cb2d1f34d3 | ||
|
|
97c8e1c9fb | ||
|
|
5485da4029 | ||
|
|
b579b32138 | ||
|
|
4337a8f69a | ||
|
|
a007ac11ce | ||
|
|
34e831f738 | ||
|
|
cfbb3d50ee | ||
|
|
1c2222da67 | ||
|
|
bb71b892ca | ||
|
|
ab81a3da3d | ||
|
|
5f72e6ce7c | ||
|
|
1ec2df27b0 | ||
|
|
1fb859fdf9 | ||
|
|
e91085b4ed | ||
|
|
2d722a89c7 | ||
|
|
c348fb55fa | ||
|
|
c7d5679788 | ||
|
|
a646dfd09f | ||
|
|
4d1e7343c3 | ||
|
|
808fe5fc68 | ||
|
|
7d9c241d17 | ||
|
|
5b8749d11f | ||
|
|
e518c988b2 | ||
|
|
12044587c9 | ||
| ecf2dab35c | |||
| 1016d8717c | |||
| e7dd2cca00 | |||
| 34f9f0cb32 | |||
| 30c66158d5 | |||
| 69c54fad1d | |||
| c20599164e | |||
| 664c72fa41 | |||
| 486a18451b | |||
|
|
9bfbafcec1 | ||
|
|
2c9898be90 | ||
| 34b2739405 | |||
| 4aaa542ded | |||
| 1049d029b4 | |||
|
|
f4e46c39fb | ||
|
|
fadd402e63 | ||
|
|
b10d41532b | ||
|
|
2047e044d5 | ||
|
|
2c86f2d515 | ||
|
|
870da3838a | ||
|
|
11726858cc | ||
|
|
1ced95f218 | ||
|
|
9e02aa3a88 | ||
|
|
9ff08d3e58 | ||
|
|
e0aacab1d1 | ||
|
|
58af463585 | ||
|
|
d54ad82095 | ||
|
|
9c28d50890 | ||
|
|
0bf270d884 | ||
|
|
c6e60cd34d | ||
|
|
c7f7492b9e | ||
|
|
4d310346c1 | ||
|
|
cc373f53fe | ||
|
|
d057ebef4a | ||
|
|
0bf18968ad | ||
|
|
27cf98eef2 | ||
|
|
f84111b9cc | ||
|
|
187cb9b283 | ||
|
|
a52db0a2be | ||
|
|
9f12aac46b | ||
|
|
0827352e6b | ||
|
|
f444888f2d | ||
|
|
65079be442 | ||
|
|
169ea52ea5 | ||
|
|
1d3f2eb9cd | ||
|
|
b5188e8a3a | ||
|
|
a4789f3578 | ||
|
|
d4aab34e43 | ||
|
|
293e4a20b9 |
18
.cursor/.prompt/1101.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
|
||||||
|
x 메인 게시판 일반
|
||||||
|
메인 게시판 프리뷰
|
||||||
|
x 메인 게시판 스페셜랭크
|
||||||
|
|
||||||
|
기본 리스트
|
||||||
|
스페셜_랭크
|
||||||
|
스페셜_출석
|
||||||
|
스페셜_제휴업체
|
||||||
|
스페셜_제휴업체지도
|
||||||
|
|
||||||
|
게시글 뷰 + 댓글
|
||||||
|
|
||||||
|
로그인관련
|
||||||
|
회원가입 페이지
|
||||||
|
회원쪽지
|
||||||
|
링크로들어오면 보이고 거기서 페이지 이동하면 안보이게
|
||||||
6
.cursor/.prompt/done/normalboardwork.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
### 일반 게시판 작업
|
||||||
|
* 게시판은 일반게시판과 특수게시판 여러 종류로 나뉨
|
||||||
|
1. 일반 게시판 작업
|
||||||
|
1-1. 일반게시판은 상단에 메인과 같은 배너가 있음
|
||||||
|
1-2 최신순, 조회순, 좋아요순, 댓글 순으로 정렬 가능
|
||||||
|
|
||||||
17
.cursor/.prompt/done/search.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
검색어 입력 폼은
|
||||||
|
|
||||||
|
|
||||||
|
width: 384px;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--gray-20, #D5D5D5);
|
||||||
|
background: var(--gray-00, #FFF);
|
||||||
|
|
||||||
|
이 모양이며 좌측에
|
||||||
|
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 21L17.682 17.682M17.682 17.682C18.4963 16.8676 19 15.7426 19 14.5C19 12.0147 16.9853 10 14.5 10C12.0147 10 10 12.0147 10 14.5C10 16.9853 12.0147 19 14.5 19C15.7426 19 16.8676 18.4963 17.682 17.682ZM28 16C28 22.6274 22.6274 28 16 28C9.37258 28 4 22.6274 4 16C4 9.37258 9.37258 4 16 4C22.6274 4 28 9.37258 28 16Z" stroke="#707070" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
이 아이콘이 들어감 placeholder는 검색어를 입력해 주세요.
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
git worktree add ../MainPage -b MainPage
|
||||||
|
|
||||||
|
|
||||||
MainPage
|
MainPage
|
||||||
AdminPage
|
AdminPage
|
||||||
BoardContentList
|
BoardContentList
|
||||||
BoardContentWrite
|
BoardContentWrite
|
||||||
BoardContentView
|
BoardContentView
|
||||||
|
|
||||||
|
커밋 테스트
|
||||||
37
.cursor/.prompt/new.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
7.게시판 열 구분선 잘보이게 요청 / 모바일 최적화 요청 및 구분선,높이 조절 요청
|
||||||
|
8.글삭제기능 어드민추가
|
||||||
|
|
||||||
|
9-1.시크로드 참고 - 제휴업체 리스트, 지역별 / 제휴업체 정보 생성 요청 (제휴업체 프로필,출근부)
|
||||||
|
9-2.제휴업체 및 프로필 등록은, 권한을부여한 제휴업체가 직접 등록하도록 해야합니다
|
||||||
|
9-3.제휴업체 등록 주인이하면안됨, 시크로드 - 제휴문의 - 제휴문의글쓰기 참고요청
|
||||||
|
제휴문의 게시판 ( 관리자 승인후) → 이동
|
||||||
|
제휴업소 리스트 게시판 (노출됨)"
|
||||||
|
|
||||||
|
10.배너관리 이미지 사이즈 규격 및 제휴업체 등록시 이미지 사이즈 규격
|
||||||
|
11,게시판, 주간인기글 , 일간인기글 추가 요청
|
||||||
|
|
||||||
|
12.https://search.google.com/search-console/welcome 등록요청
|
||||||
|
13.메인페이지 제휴업체 배너 이미지 규격작게 수정요청, 한번에 4개이상 정도 보이는 정도
|
||||||
|
14.메인페이지 제휴업체 클릭시 제휴업체 카테고리 하이퍼연결 추가 요청
|
||||||
|
15.레벨별 아이콘 페이지 / https://seekrod.co.kr/bbs/page.php?hid=point 참고
|
||||||
|
16. 게시글 상단고정 기능
|
||||||
|
17. 메인화면 큰 카테고리 빼기
|
||||||
|
18. google SEO: header, meta, description 동적설정
|
||||||
|
2. 회색 배경 영역(비밀글, 글자수)이 글 작성 영역 안에 있어야 함
|
||||||
|
로그인 프로세스 수정: 로그인시 로그인 팝업 모달
|
||||||
|
로그인 로그오프 시, 메인 프로필 디자인 누락되서 추가(지금상태로 가도 무상관)
|
||||||
|
1. 게시글에서 목록돌아가는 버튼 디자인과 다름
|
||||||
|
2. 게시글에서 제목, 내용, 댓글 디자인 다름
|
||||||
|
3. 게시글에서 최하단에 게시글리스트 부분 디자인 다
|
||||||
|
4. 게시글 리스트에서 리스트버튼 하단 divider 색상이 너무 연함
|
||||||
|
5. 표시개수 필요없을 거 같은데, 그냥 고정해버리죠?
|
||||||
|
|
||||||
|
9. 게시글 이미지 배치사이즈
|
||||||
|
10. 외부접속 가입없어도 가능
|
||||||
|
11. 글뷰에서 게시글 리스트로
|
||||||
|
12. 게시글 리스트 디자인
|
||||||
|
13. 포인트 규칙
|
||||||
|
14. 게시판권한 확인
|
||||||
|
15.메인뷰 3열 사이즈 변경
|
||||||
|
16. 로그인 안됐을때 카드 휑함
|
||||||
|
17.이미지 사이즈 미리불러오기
|
||||||
36
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: deploy-on-main
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: [ self-hosted, linux_amd64 ]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Node / PM2 / Prisma 확인
|
||||||
|
run: |
|
||||||
|
node -v
|
||||||
|
npm -v
|
||||||
|
pm2 -v || true
|
||||||
|
npx prisma --version || true
|
||||||
|
|
||||||
|
- name: 배포
|
||||||
|
env:
|
||||||
|
APP_DIR: /root/msgapp
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd "$APP_DIR"
|
||||||
|
# 최신 main 코드 반영
|
||||||
|
git fetch origin main
|
||||||
|
git reset --hard origin/main
|
||||||
|
|
||||||
|
npm ci
|
||||||
|
npm run dbforce
|
||||||
|
npm run build
|
||||||
|
pm2 reload ecosystem.config.js --env production || (pm2 start ecosystem.config.js --env production && pm2 save)
|
||||||
|
pm2 list
|
||||||
1
.gitignore
vendored
@@ -46,3 +46,4 @@ next-env.d.ts
|
|||||||
/prisma/prisma/dev.db
|
/prisma/prisma/dev.db
|
||||||
|
|
||||||
*.ignore
|
*.ignore
|
||||||
|
/logs
|
||||||
|
|||||||
13
.runner
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"WARNING": "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.",
|
||||||
|
"id": 2,
|
||||||
|
"uuid": "7dc76cf1-fcf2-48ba-a97a-73fb5d661049",
|
||||||
|
"name": "msgapp",
|
||||||
|
"token": "f4391f32ea51657c4366698db1b8333b5d5e1b83",
|
||||||
|
"address": "https://www.plubu.com/",
|
||||||
|
"labels": [
|
||||||
|
"linux_amd64:host",
|
||||||
|
"self-hosted:host"
|
||||||
|
],
|
||||||
|
"ephemeral": false
|
||||||
|
}
|
||||||
6
deploytest.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
test
|
||||||
|
new address check
|
||||||
|
new address check2
|
||||||
|
deploy test2dfdf213
|
||||||
|
deploy test001
|
||||||
|
deploy test002
|
||||||
18
ecosystem.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'assm',
|
||||||
|
cwd: '/root/msgapp',
|
||||||
|
script: 'npm',
|
||||||
|
args: 'start',
|
||||||
|
interpreter: 'none',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
},
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
3
middleware.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { middleware, config } from "./src/middleware";
|
||||||
|
|
||||||
|
|
||||||
12
package.json
@@ -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",
|
||||||
|
|||||||
55
prisma/migrations/20251031063728_/migration.sql
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "board_view_types" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"scope" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 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',
|
||||||
|
"type" TEXT NOT NULL DEFAULT 'general',
|
||||||
|
"requiresApproval" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"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", "name", "readLevel", "requiredFields", "requiredTags", "requiresApproval", "slug", "sortOrder", "status", "type", "updatedAt", "writeLevel") SELECT "allowAnonymousPost", "allowSecretComment", "categoryId", "createdAt", "description", "id", "isAdultOnly", "name", "readLevel", "requiredFields", "requiredTags", "requiresApproval", "slug", "sortOrder", "status", "type", "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_type_requiresApproval_idx" ON "boards"("type", "requiresApproval");
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "board_view_types_key_key" ON "board_view_types"("key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "board_view_types_scope_idx" ON "board_view_types"("scope");
|
||||||
11
prisma/migrations/20251031070529_/migration.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "settings" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "settings_key_key" ON "settings"("key");
|
||||||
18
prisma/migrations/20251101152100_/migration.sql
Normal 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");
|
||||||
2
prisma/migrations/20251101161632_/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "partners" ADD COLUMN "imageUrl" TEXT;
|
||||||
23
prisma/migrations/20251101162445_/migration.sql
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|
||||||
43
prisma/migrations/20251102030623_/migration.sql
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -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) // 성인 인증 필요 여부
|
||||||
@@ -141,6 +137,12 @@ model Board {
|
|||||||
categoryId String?
|
categoryId String?
|
||||||
category BoardCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
category BoardCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
// 뷰 타입 설정 (동적 테이블 참조)
|
||||||
|
mainPageViewTypeId String?
|
||||||
|
mainPageViewType BoardViewType? @relation("MainPageViewType", fields: [mainPageViewTypeId], references: [id], onDelete: SetNull)
|
||||||
|
listViewTypeId String?
|
||||||
|
listViewType BoardViewType? @relation("ListViewType", fields: [listViewTypeId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
posts Post[]
|
posts Post[]
|
||||||
moderators BoardModerator[]
|
moderators BoardModerator[]
|
||||||
|
|
||||||
@@ -148,11 +150,30 @@ model Board {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([status, sortOrder])
|
@@index([status, sortOrder])
|
||||||
@@index([type, requiresApproval])
|
|
||||||
@@index([categoryId])
|
@@index([categoryId])
|
||||||
|
@@index([mainPageViewTypeId])
|
||||||
|
@@index([listViewTypeId])
|
||||||
@@map("boards")
|
@@map("boards")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 게시판 뷰 타입 정의 (enum 대신 데이터 테이블)
|
||||||
|
model BoardViewType {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String @unique // 예: preview, text, special_rank
|
||||||
|
name String // 표시용 이름
|
||||||
|
scope String // 용도 구분: 'main' | 'list' 등 자유 텍스트
|
||||||
|
|
||||||
|
// 역참조
|
||||||
|
mainBoards Board[] @relation("MainPageViewType")
|
||||||
|
listBoards Board[] @relation("ListViewType")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([scope])
|
||||||
|
@@map("board_view_types")
|
||||||
|
}
|
||||||
|
|
||||||
// 게시판 운영진 매핑
|
// 게시판 운영진 매핑
|
||||||
model BoardModerator {
|
model BoardModerator {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -196,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])
|
||||||
@@ -204,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)
|
||||||
@@ -234,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")
|
||||||
@@ -299,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)
|
||||||
@@ -308,10 +337,13 @@ model Comment {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||||
|
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
|
replies Comment[] @relation("CommentReplies")
|
||||||
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
|
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
|
||||||
reports Report[]
|
reports Report[]
|
||||||
|
|
||||||
@@index([postId, createdAt])
|
@@index([postId, createdAt])
|
||||||
|
@@index([parentId])
|
||||||
@@map("comments")
|
@@map("comments")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,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())
|
||||||
@@ -476,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())
|
||||||
@@ -644,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())
|
||||||
@@ -696,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?
|
||||||
@@ -703,3 +788,17 @@ model PartnerRequest {
|
|||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
@@map("partner_requests")
|
@@map("partner_requests")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 시스템 설정 (key-value 형태)
|
||||||
|
model Setting {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String @unique
|
||||||
|
value String // JSON 문자열로 저장
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 노출용 제휴 샵 가로 스크롤 데이터
|
||||||
|
// PartnerShop 모델 제거됨 (PartnerRequest로 통합)
|
||||||
|
|||||||
527
prisma/seed.js
@@ -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);
|
||||||
@@ -207,6 +431,63 @@ async function upsertBoards(admin, categoryMap) {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function upsertViewTypes() {
|
||||||
|
const viewTypes = [
|
||||||
|
// main scope (기본/없음 제거, 텍스트 중심)
|
||||||
|
{ key: "main_text", name: "텍스트", scope: "main" },
|
||||||
|
{ key: "main_preview", name: "미리보기", scope: "main" },
|
||||||
|
{ key: "main_special_rank", name: "특수랭킹", scope: "main" },
|
||||||
|
// list scope (기본/없음 제거, 텍스트 중심)
|
||||||
|
{ key: "list_text", name: "텍스트", scope: "list" },
|
||||||
|
{ key: "list_preview", name: "미리보기", scope: "list" },
|
||||||
|
{ key: "list_special_rank", name: "특수랭킹", scope: "list" },
|
||||||
|
{ key: "list_special_attendance", name: "특수출석", scope: "list" },
|
||||||
|
];
|
||||||
|
for (const vt of viewTypes) {
|
||||||
|
await prisma.boardViewType.upsert({
|
||||||
|
where: { key: vt.key },
|
||||||
|
update: { name: vt.name, scope: vt.scope },
|
||||||
|
create: vt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = [
|
||||||
@@ -237,40 +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();
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 795 KiB After Width: | Height: | Size: 836 KiB |
9
public/svgs/01_bronze.svg
Normal file
|
After Width: | Height: | Size: 99 KiB |
9
public/svgs/02_silver.svg.svg
Normal file
|
After Width: | Height: | Size: 69 KiB |
9
public/svgs/03_gold.svg
Normal file
|
After Width: | Height: | Size: 70 KiB |
9
public/svgs/04_platinum.svg
Normal file
|
After Width: | Height: | Size: 84 KiB |
9
public/svgs/05_diamond.svg
Normal file
|
After Width: | Height: | Size: 92 KiB |
9
public/svgs/06_master.svg
Normal file
|
After Width: | Height: | Size: 96 KiB |
9
public/svgs/07_grandmaster.svg
Normal file
|
After Width: | Height: | Size: 95 KiB |
9
public/svgs/08_god.svg
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
public/uploads/1761998092426-cdxtisa8vi6.webp
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
public/uploads/1762011428045-b2uxup0646v.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762011528689-r5qbw3daoq.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762012666810-zhoeib9y8we.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762013315968-jent9fluatl.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762013865590-izqoqn8qgbm.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762014531262-2vmcxdk945u.jpg
Normal file
|
After Width: | Height: | Size: 411 KiB |
BIN
public/uploads/1762014639578-9e1067twpw.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/uploads/1762014695925-jmes4cxd0vd.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762014716631-fgq5a179wwr.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762014821297-9qbwphmxm05.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762015671690-p67kkblxdml.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762015830912-1wsv0cfchd8.jpg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
public/uploads/1762016086149-vcxoon8tg8.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762016548518-0d2zhs3f44bq.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762017507500-08hp85ex35v.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762017553592-w9qnbapfb2.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762017624031-rni0unzdl6c.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/uploads/1762025382776-83vifeqk7rk.webp
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
public/uploads/1762049641372-u0moc5pbmuj.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
public/uploads/1762049648293-vgw2mda8rx7.webp
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
public/uploads/1762049842993-8hlvwqbkgo8.webp
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
public/uploads/1762049846758-2begnvv4hxd.webp
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
public/uploads/1762049851828-lvzzq326a1.webp
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
public/uploads/1762052938422-has0h33j4x6.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/uploads/1762053004600-0ewlk5af03i.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/uploads/1762059929501-cxe093gyep7.webp
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
public/uploads/1762060014485-401jthedn6x.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/uploads/1762060051441-h7kz3p9myc6.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762060249110-ut9vnyatzc.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/uploads/1762060270463-di302vcwbg.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
public/uploads/1762060431071-kpio217ffh7.webp
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
public/uploads/1762349790327-56eucbsdkiy.webp
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
public/uploads/1762349798482-lg2199h4w0h.webp
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
public/uploads/1762439529544-pfdpsiv372l.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/uploads/1762439795788-41zbv74p6l9.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762444179265-fuj8zoahblc.jpg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
public/uploads/1762520156525-1dqijvt0rge.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/uploads/1762521903585-d2gxpaoocil.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/uploads/1762694665640-mepawpoqguh.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762694722874-13r02smuxh0n.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762694815229-575v2kyj72x.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762695071878-s0a8nautp7d.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762695378037-rbj4gzlxveq.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762696083342-gwebeuwl0q4.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/uploads/1762696149731-1fom3wudm94.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/uploads/1762701326416-gknp8r0e4af.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762702099453-si2e8ubylu9.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/uploads/1762703335687-i85lpr0bgo.webp
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/uploads/1762704770941-j2nzhl8ww1.webp
Normal file
|
After Width: | Height: | Size: 279 KiB |
@@ -4,17 +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: "배너" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
|
||||||
<h1>배너 관리</h1>
|
<h1>배너 관리</h1>
|
||||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
<button
|
||||||
<input placeholder="제목" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
type="button"
|
||||||
<input placeholder="이미지 URL" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
|
onClick={() => setShowCreateModal(true)}
|
||||||
<input placeholder="링크 URL(선택)" value={form.linkUrl} onChange={(e) => setForm({ ...form, linkUrl: e.target.value })} />
|
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"
|
||||||
<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>
|
</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>
|
||||||
|
|||||||
@@ -1,14 +1,52 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState, useEffect, useRef } from "react";
|
||||||
|
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||||
|
|
||||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
export default function AdminBoardsPage() {
|
export default function AdminBoardsPage() {
|
||||||
|
const { show } = useToast();
|
||||||
const { data: boardsResp, mutate: mutateBoards } = useSWR<{ boards: any[] }>("/api/admin/boards", fetcher);
|
const { data: boardsResp, mutate: mutateBoards } = useSWR<{ boards: any[] }>("/api/admin/boards", fetcher);
|
||||||
|
const { data: vtResp, mutate: mutateVt } = useSWR<{ items: any[] }>("/api/admin/view-types", fetcher);
|
||||||
const { data: catsResp, mutate: mutateCats } = useSWR<{ categories: any[] }>("/api/admin/categories", fetcher);
|
const { data: catsResp, mutate: mutateCats } = useSWR<{ categories: any[] }>("/api/admin/categories", fetcher);
|
||||||
const boards = boardsResp?.boards ?? [];
|
const rawBoards = boardsResp?.boards ?? [];
|
||||||
const categories = (catsResp?.categories ?? []).sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
const rawCategories = catsResp?.categories ?? [];
|
||||||
|
|
||||||
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
|
const [dirtyBoards, setDirtyBoards] = useState<Record<string, any>>({});
|
||||||
|
const [dirtyCats, setDirtyCats] = useState<Record<string, any>>({});
|
||||||
|
const [savingAll, setSavingAll] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
const dirtyCount = Object.keys(dirtyBoards).length + Object.keys(dirtyCats).length;
|
||||||
|
const [catOrder, setCatOrder] = useState<string[]>([]);
|
||||||
|
const [draggingCatIndex, setDraggingCatIndex] = useState<number | null>(null);
|
||||||
|
const catRefs = useRef<Record<string, HTMLLIElement | null>>({});
|
||||||
|
const [boardOrderByCat, setBoardOrderByCat] = useState<Record<string, string[]>>({});
|
||||||
|
const [draggingBoard, setDraggingBoard] = useState<{ catId: string; index: number } | null>(null);
|
||||||
|
const boardRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
|
||||||
|
const boards = useMemo(() => {
|
||||||
|
return rawBoards.map((b: any) => ({ ...b, ...(dirtyBoards[b.id] ?? {}) }));
|
||||||
|
}, [rawBoards, dirtyBoards]);
|
||||||
|
const viewTypes = vtResp?.items ?? [];
|
||||||
|
const mainTypes = useMemo(() => viewTypes.filter((v: any) => v.scope === 'main'), [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 defaultListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_default' || t.key === 'default')?.id ?? null), [listTypes]);
|
||||||
|
const textMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_text')?.id ?? null), [mainTypes]);
|
||||||
|
const textListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_text')?.id ?? null), [listTypes]);
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const merged = rawCategories.map((c: any) => ({ ...c, ...(dirtyCats[c.id] ?? {}) }));
|
||||||
|
return merged.sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||||
|
}, [rawCategories, dirtyCats]);
|
||||||
|
const orderedCats = useMemo(() => {
|
||||||
|
if (!catOrder.length) return categories;
|
||||||
|
const map = new Map(categories.map((c: any) => [c.id, c]));
|
||||||
|
const ordered = catOrder.map((id) => map.get(id)).filter(Boolean) as any[];
|
||||||
|
// 새로 생긴 카테고리(id 미포함)는 뒤에 추가
|
||||||
|
const missing = categories.filter((c: any) => !catOrder.includes(c.id));
|
||||||
|
return [...ordered, ...missing];
|
||||||
|
}, [categories, catOrder]);
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
const map: Record<string, any[]> = {};
|
const map: Record<string, any[]> = {};
|
||||||
for (const b of boards) {
|
for (const b of boards) {
|
||||||
@@ -16,13 +54,34 @@ export default function AdminBoardsPage() {
|
|||||||
if (!map[cid]) map[cid] = [];
|
if (!map[cid]) map[cid] = [];
|
||||||
map[cid].push(b);
|
map[cid].push(b);
|
||||||
}
|
}
|
||||||
return categories.map((c: any) => ({ ...c, items: (map[c.id] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) }));
|
const result = orderedCats.map((c: any) => {
|
||||||
}, [boards, categories]);
|
const itemsSorted = (map[c.id] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||||
|
const custom = boardOrderByCat[c.id];
|
||||||
|
if (!custom || custom.length === 0) return { ...c, items: itemsSorted };
|
||||||
|
const byId = new Map(itemsSorted.map((x: any) => [x.id, x]));
|
||||||
|
const ordered = custom.map((id) => byId.get(id)).filter(Boolean) as any[];
|
||||||
|
const missing = itemsSorted.filter((x: any) => !custom.includes(x.id));
|
||||||
|
return { ...c, items: [...ordered, ...missing] };
|
||||||
|
});
|
||||||
|
// 미분류(카테고리 없음) 그룹을 마지막에 추가
|
||||||
|
const uncat = (map["uncat"] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||||
|
if (uncat.length) {
|
||||||
|
result.push({ id: "uncat", name: "미분류", slug: "uncategorized", items: uncat });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [boards, orderedCats]);
|
||||||
|
|
||||||
const [savingId, setSavingId] = useState<string | null>(null);
|
// 최초/데이터 변경 시 표시용 카테고리 순서를 초기화
|
||||||
const [dirtyBoards, setDirtyBoards] = useState<Record<string, any>>({});
|
// 서버 sortOrder에 맞춰 초기 catOrder 설정
|
||||||
const [dirtyCats, setDirtyCats] = useState<Record<string, any>>({});
|
// categories가 바뀔 때만 동기화
|
||||||
const [savingAll, setSavingAll] = useState(false);
|
// 사용자가 드래그로 순서를 바꾸면 catOrder가 우선됨
|
||||||
|
useEffect(() => {
|
||||||
|
if (draggingCatIndex !== null) return; // 드래그 중에는 catOrder를 리셋하지 않음
|
||||||
|
const next = categories.map((c: any) => c.id);
|
||||||
|
if (next.length && (next.length !== catOrder.length || next.some((id, i) => id !== catOrder[i]))) {
|
||||||
|
setCatOrder(next);
|
||||||
|
}
|
||||||
|
}, [categories, draggingCatIndex]);
|
||||||
async function save(b: any) {
|
async function save(b: any) {
|
||||||
setSavingId(b.id);
|
setSavingId(b.id);
|
||||||
await fetch(`/api/admin/boards/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
|
await fetch(`/api/admin/boards/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
|
||||||
@@ -30,17 +89,74 @@ export default function AdminBoardsPage() {
|
|||||||
mutateBoards();
|
mutateBoards();
|
||||||
}
|
}
|
||||||
|
|
||||||
// DnD: 카테고리 순서 변경
|
// 버튼으로 카테고리 순서 이동 (↑/↓)
|
||||||
async function reorderCategories(next: any[]) {
|
function moveCategory(catId: string, delta: number) {
|
||||||
// optimistic update
|
const baseIds = (catOrder.length ? catOrder : categories.map((c: any) => c.id));
|
||||||
await Promise.all(next.map((c, idx) => fetch(`/api/admin/categories/${c.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: idx + 1 }) })));
|
const idx = baseIds.indexOf(catId);
|
||||||
mutateCats();
|
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: 카테고리 순서 변경 (저장 시 반영)
|
||||||
async function reorderBoards(categoryId: string, nextItems: any[]) {
|
function reorderCategories(next: any[]) {
|
||||||
await Promise.all(nextItems.map((b, idx) => fetch(`/api/admin/boards/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: idx + 1, categoryId }) })));
|
setDirtyCats((prev) => {
|
||||||
mutateBoards();
|
// 서버 기준(또는 이전 dirty 오버라이드) sortOrder 맵 구성
|
||||||
|
const baseSort = new Map<string, number>();
|
||||||
|
// rawCategories에는 서버 값이 들어있음
|
||||||
|
// prev에 sortOrder가 있으면 우선 적용
|
||||||
|
for (const c of rawCategories) {
|
||||||
|
const prevOverride = prev[c.id]?.sortOrder;
|
||||||
|
baseSort.set(c.id, prevOverride ?? (c.sortOrder ?? 0));
|
||||||
|
}
|
||||||
|
const updated: Record<string, any> = { ...prev };
|
||||||
|
next.forEach((c, idx) => {
|
||||||
|
const target = idx + 1;
|
||||||
|
const current = baseSort.get(c.id) ?? 0;
|
||||||
|
if (target !== current) {
|
||||||
|
updated[c.id] = { ...(updated[c.id] ?? {}), sortOrder: target };
|
||||||
|
} else if (updated[c.id]?.sortOrder !== undefined) {
|
||||||
|
// 정렬값이 동일해졌다면 해당 키만 제거 (다른 수정값은 유지)
|
||||||
|
const { sortOrder, ...rest } = updated[c.id];
|
||||||
|
if (Object.keys(rest).length === 0) delete updated[c.id]; else updated[c.id] = rest;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DnD: 보드 순서 변경 (저장 시 반영)
|
||||||
|
function reorderBoards(categoryId: string, nextItems: any[]) {
|
||||||
|
setDirtyBoards((prev) => {
|
||||||
|
// 서버 기준(또는 이전 dirty) 정렬/카테고리 맵 구성
|
||||||
|
const base = new Map<string, { sortOrder: number; categoryId: string | null | undefined }>();
|
||||||
|
for (const b of rawBoards) {
|
||||||
|
const prevB = prev[b.id] ?? {};
|
||||||
|
base.set(b.id, {
|
||||||
|
sortOrder: prevB.sortOrder ?? (b.sortOrder ?? 0),
|
||||||
|
categoryId: prevB.categoryId ?? b.categoryId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const updated: Record<string, any> = { ...prev };
|
||||||
|
nextItems.forEach((b, idx) => {
|
||||||
|
const targetSort = idx + 1;
|
||||||
|
const targetCat = categoryId === "uncat" ? null : categoryId;
|
||||||
|
const baseVal = base.get(b.id) ?? { sortOrder: b.sortOrder ?? 0, categoryId: b.categoryId };
|
||||||
|
if (baseVal.sortOrder !== targetSort || baseVal.categoryId !== targetCat) {
|
||||||
|
updated[b.id] = { ...(updated[b.id] ?? {}), sortOrder: targetSort, categoryId: targetCat };
|
||||||
|
} else if (updated[b.id]) {
|
||||||
|
const { sortOrder, categoryId: catId, ...rest } = updated[b.id];
|
||||||
|
if (Object.keys(rest).length === 0) delete updated[b.id]; else updated[b.id] = rest;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function markBoardDirty(id: string, draft: any) {
|
function markBoardDirty(id: string, draft: any) {
|
||||||
@@ -49,106 +165,331 @@ export default function AdminBoardsPage() {
|
|||||||
function markCatDirty(id: string, draft: any) {
|
function markCatDirty(id: string, draft: any) {
|
||||||
setDirtyCats((prev) => ({ ...prev, [id]: draft }));
|
setDirtyCats((prev) => ({ ...prev, [id]: draft }));
|
||||||
}
|
}
|
||||||
|
function toggleCat(id: string) {
|
||||||
|
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
}
|
||||||
async function saveAll() {
|
async function saveAll() {
|
||||||
|
const prevDirtyBoards = dirtyBoards;
|
||||||
|
const prevDirtyCats = dirtyCats;
|
||||||
try {
|
try {
|
||||||
setSavingAll(true);
|
setSavingAll(true);
|
||||||
const boardEntries = Object.entries(dirtyBoards);
|
const boardEntries = Object.entries(dirtyBoards);
|
||||||
const catEntries = Object.entries(dirtyCats);
|
const catEntries = Object.entries(dirtyCats);
|
||||||
await Promise.all([
|
// 1) 서버 저장 (병렬) - 실패 시 아래 catch로 이동
|
||||||
|
const resps = await Promise.all([
|
||||||
...boardEntries.map(([id, payload]) => fetch(`/api/admin/boards/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) })),
|
...boardEntries.map(([id, payload]) => fetch(`/api/admin/boards/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) })),
|
||||||
...catEntries.map(([id, payload]) => fetch(`/api/admin/categories/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) })),
|
...catEntries.map(([id, payload]) => fetch(`/api/admin/categories/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) })),
|
||||||
]);
|
]);
|
||||||
|
const anyFail = resps.some((r) => !r.ok);
|
||||||
|
if (anyFail) throw new Error("save_failed");
|
||||||
|
// 2) 성공 시: 먼저 서버 데이터로 최신화 → 그 다음 dirty 초기화
|
||||||
|
await Promise.all([
|
||||||
|
mutateBoards(undefined, { revalidate: true }),
|
||||||
|
mutateCats(undefined, { revalidate: true }),
|
||||||
|
]);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new Event("categories:reload"));
|
||||||
|
}
|
||||||
setDirtyBoards({});
|
setDirtyBoards({});
|
||||||
setDirtyCats({});
|
setDirtyCats({});
|
||||||
mutateBoards();
|
} catch (e) {
|
||||||
mutateCats();
|
// 실패 시: 변경 사항 취소하고 서버 상태로 되돌림
|
||||||
|
setDirtyBoards({});
|
||||||
|
setDirtyCats({});
|
||||||
|
await Promise.all([
|
||||||
|
mutateBoards(undefined, { revalidate: true }),
|
||||||
|
mutateCats(undefined, { revalidate: true }),
|
||||||
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingAll(false);
|
setSavingAll(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 생성/삭제 액션들
|
||||||
|
async function createCategory() {
|
||||||
|
const name = prompt("대분류 이름을 입력하세요");
|
||||||
|
if (!name) return;
|
||||||
|
const slug = prompt("대분류 slug을 입력하세요(영문)");
|
||||||
|
if (!slug) return;
|
||||||
|
const dupName = categories.some((c: any) => c.name === name);
|
||||||
|
const dupSlug = categories.some((c: any) => c.slug === slug);
|
||||||
|
if (dupName || dupSlug) {
|
||||||
|
show(dupName ? "대분류 이름이 중복입니다." : "대분류 slug가 중복입니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetch(`/api/admin/categories`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, sortOrder: categories.length + 1, status: "active" }) });
|
||||||
|
await mutateCats();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCategory(id: string) {
|
||||||
|
if (!confirm("대분류를 삭제하시겠습니까? 소분류의 카테고리는 해제됩니다.")) return;
|
||||||
|
await fetch(`/api/admin/categories/${id}`, { method: "DELETE" });
|
||||||
|
await mutateCats();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBoard(catId: string, currentItems: any[]) {
|
||||||
|
const name = prompt("소분류(게시판) 이름을 입력하세요");
|
||||||
|
if (!name) return;
|
||||||
|
const slug = prompt("소분류 slug을 입력하세요(영문)");
|
||||||
|
if (!slug) return;
|
||||||
|
const dupName = boards.some((b: any) => b.name === name);
|
||||||
|
const dupSlug = boards.some((b: any) => b.slug === slug);
|
||||||
|
if (dupName || dupSlug) {
|
||||||
|
show(dupName ? "게시판 이름이 중복입니다." : "게시판 slug가 중복입니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sortOrder = (currentItems?.length ?? 0) + 1;
|
||||||
|
await fetch(`/api/admin/boards`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, categoryId: catId === "uncat" ? null : catId, sortOrder, status: "active", mainPageViewTypeId: textMainTypeId, listViewTypeId: textListTypeId }) });
|
||||||
|
await mutateBoards();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBoard(id: string) {
|
||||||
|
if (!confirm("해당 소분류(게시판)를 삭제하시겠습니까?")) return;
|
||||||
|
await fetch(`/api/admin/boards/${id}`, { method: "DELETE" });
|
||||||
|
await mutateBoards();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveBoardToCategory(boardId: string, toCategoryId: string) {
|
||||||
|
try {
|
||||||
|
const target = boards.find((x: any) => x.id === boardId);
|
||||||
|
if (!target) return;
|
||||||
|
const targetGroup = groups.find((g: any) => g.id === toCategoryId);
|
||||||
|
// 미분류로 이동 시 uncat 그룹 기준으로 정렬 순서 계산
|
||||||
|
const nextOrder = (toCategoryId === 'uncat'
|
||||||
|
? (groups.find((g: any) => g.id === 'uncat')?.items?.length ?? 0)
|
||||||
|
: (targetGroup?.items?.length ?? 0)) + 1;
|
||||||
|
const res = await fetch(`/api/admin/boards/${boardId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ categoryId: toCategoryId === 'uncat' ? null : toCategoryId, sortOrder: nextOrder }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("move_failed");
|
||||||
|
await mutateBoards();
|
||||||
|
show("이동되었습니다.");
|
||||||
|
} catch {
|
||||||
|
show("이동 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-xl md:text-2xl font-bold text-neutral-900">게시판 관리</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-neutral-900">게시판 관리</h1>
|
||||||
{/* 변경사항 저장 바 */}
|
|
||||||
{(Object.keys(dirtyBoards).length + Object.keys(dirtyCats).length) > 0 && (
|
|
||||||
<div className="sticky top-2 z-10 flex justify-end">
|
<div className="sticky top-2 z-10 flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={saveAll}
|
onClick={saveAll}
|
||||||
disabled={savingAll}
|
disabled={savingAll || dirtyCount === 0}
|
||||||
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow disabled:opacity-60"
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow disabled:opacity-60"
|
||||||
>{savingAll ? "저장 중..." : `변경사항 저장 (${Object.keys(dirtyBoards).length + Object.keys(dirtyCats).length})`}</button>
|
>{savingAll ? "저장 중..." : `변경사항 저장 (${dirtyCount})`}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대분류(헤더)와 소분류(보드 테이블)를 하나의 리스트로 통합, 대분류별 토글 */}
|
||||||
|
<div className="rounded-xl border border-neutral-200 overflow-hidden bg-white">
|
||||||
|
<div className="px-4 py-2 border-b border-neutral-200 text-sm font-semibold flex items-center justify-between">
|
||||||
|
<span>게시판 리스트</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-8 px-3 rounded-md border border-neutral-300 text-sm hover:bg-neutral-50"
|
||||||
|
onClick={createCategory}
|
||||||
|
>대분류 추가</button>
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y-2 divide-neutral-100">
|
||||||
|
{groups.map((g, idx) => (
|
||||||
|
<li key={g.id} className="select-none">
|
||||||
|
<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>
|
||||||
|
{g.id === 'uncat' ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 text-xl text-neutral-400 select-none">—</div>
|
||||||
|
<div className="text-sm font-medium text-neutral-800">미분류 (카테고리 없음)</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CategoryHeaderContent g={g} onDirty={(payload) => markCatDirty(g.id, { ...payload })} />
|
||||||
|
)}
|
||||||
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
{/* 대분류 리스트 (드래그로 순서 변경) */}
|
type="button"
|
||||||
<div className="rounded-xl border border-neutral-200 overflow-hidden bg-white">
|
aria-label="toggle"
|
||||||
<div className="px-4 py-2 border-b border-neutral-200 text-sm font-semibold">대분류</div>
|
className="w-8 text-2xl leading-none text-neutral-700"
|
||||||
<ul className="divide-y divide-neutral-100">
|
onClick={() => toggleCat(g.id)}
|
||||||
{groups.map((g, idx) => (
|
>{expanded[g.id] ? '▾' : '▸'}</button>
|
||||||
<CategoryRow key={g.id} idx={idx} g={g} onMove={(from, to) => {
|
{g.id !== 'uncat' && (
|
||||||
const arr = [...groups];
|
<>
|
||||||
const [moved] = arr.splice(from, 1);
|
<label className="ml-auto flex items-center gap-2 text-sm text-neutral-700">
|
||||||
arr.splice(to, 0, moved);
|
<input
|
||||||
reorderCategories(arr);
|
type="checkbox"
|
||||||
}} onDirty={(payload) => markCatDirty(g.id, { ...payload })} />
|
checked={(g.status ?? 'active') !== 'hidden'}
|
||||||
))}
|
onChange={(e) => markCatDirty(g.id, { status: e.target.checked ? 'active' : 'hidden' })}
|
||||||
</ul>
|
/>
|
||||||
|
활성
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-8 px-3 rounded-md border border-red-300 text-sm text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => deleteCategory(g.id)}
|
||||||
|
>대분류 삭제</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{groups.map((g) => (
|
{expanded[g.id] && (
|
||||||
<section key={g.id} className="rounded-xl border border-neutral-200 overflow-hidden bg-white">
|
<div className="overflow-x-auto border-t border-neutral-100 ml-8">
|
||||||
<div className="px-4 py-2 border-b border-neutral-200 flex items-center justify-between">
|
<div className="flex items-center justify-end p-2">
|
||||||
<div className="text-sm font-semibold">대분류: {g.name}</div>
|
<button
|
||||||
<div className="text-xs text-neutral-500">slug: {g.slug}</div>
|
type="button"
|
||||||
|
className="h-8 px-3 rounded-md border border-neutral-300 text-sm hover:bg-neutral-50"
|
||||||
|
onClick={() => createBoard(g.id, g.items)}
|
||||||
|
>소분류 추가</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<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 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>
|
||||||
<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>
|
||||||
|
<th className="px-3 py-2">삭제</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-neutral-100">
|
<tbody className="divide-y divide-neutral-100">
|
||||||
{g.items.map((b, i) => (
|
{g.items.map((b, i) => (
|
||||||
<DraggableRow
|
<DraggableRow
|
||||||
key={b.id}
|
key={b.id}
|
||||||
|
catId={g.id}
|
||||||
|
boardId={b.id}
|
||||||
index={i}
|
index={i}
|
||||||
onMove={(from, to) => {
|
totalCount={g.items.length}
|
||||||
const list = [...g.items];
|
onMoveIndex={(delta) => {
|
||||||
const [mv] = list.splice(from, 1);
|
const ids = (boardOrderByCat[g.id]?.length ? boardOrderByCat[g.id] : g.items.map((x: any) => x.id));
|
||||||
list.splice(to, 0, mv);
|
const from = i;
|
||||||
reorderBoards(g.id, list);
|
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 b={b} onDirty={(id, draft) => markBoardDirty(id, draft)} />
|
<BoardRowCells
|
||||||
|
b={b}
|
||||||
|
onDirty={(id, draft) => markBoardDirty(id, draft)}
|
||||||
|
onDelete={() => deleteBoard(b.id)}
|
||||||
|
allowMove={true}
|
||||||
|
categories={g.id === 'uncat'
|
||||||
|
? orderedCats
|
||||||
|
: [{ id: 'uncat', name: '미분류' }, ...orderedCats.filter((c: any) => c.id !== g.id)]}
|
||||||
|
onMove={(toId) => moveBoardToCategory(b.id, toId)}
|
||||||
|
mainTypes={mainTypes}
|
||||||
|
listTypes={listTypes}
|
||||||
|
defaultMainTypeId={defaultMainTypeId}
|
||||||
|
defaultListTypeId={defaultListTypeId}
|
||||||
|
onAddType={async (scope: 'main'|'list') => {
|
||||||
|
const key = prompt(`${scope === 'main' ? '메인뷰' : '리스트뷰'} key (예: preview)`);
|
||||||
|
if (!key) return null;
|
||||||
|
const name = prompt(`${scope === 'main' ? '메인뷰' : '리스트뷰'} 표시 이름`);
|
||||||
|
if (!name) return null;
|
||||||
|
const res = await fetch('/api/admin/view-types', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key, name, scope }) });
|
||||||
|
if (!res.ok) { show('타입 추가 실패'); return null; }
|
||||||
|
await mutateVt();
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return data?.item?.id ?? null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</DraggableRow>
|
</DraggableRow>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
)}
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BoardRowCells({ b, onDirty }: { b: any; onDirty: (id: string, draft: any) => void }) {
|
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 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">
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-neutral-300 px-2 text-sm"
|
||||||
|
value={effectiveMainTypeId}
|
||||||
|
onChange={async (e) => {
|
||||||
|
if (e.target.value === '__add__') {
|
||||||
|
const id = (await onAddType?.('main')) ?? null;
|
||||||
|
if (id) { const v = { ...edit, mainPageViewTypeId: id }; setEdit(v); onDirty(b.id, v); }
|
||||||
|
e.currentTarget.value = id ?? '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const v = { ...edit, mainPageViewTypeId: e.target.value };
|
||||||
|
setEdit(v); onDirty(b.id, v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(selectableMainTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||||
|
<option value="__add__">+ 새 타입 추가…</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-neutral-300 px-2 text-sm"
|
||||||
|
value={effectiveListTypeId}
|
||||||
|
onChange={async (e) => {
|
||||||
|
if (e.target.value === '__add__') {
|
||||||
|
const id = (await onAddType?.('list')) ?? null;
|
||||||
|
if (id) { const v = { ...edit, listViewTypeId: id }; setEdit(v); onDirty(b.id, v); }
|
||||||
|
e.currentTarget.value = id ?? '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const v = { ...edit, listViewTypeId: e.target.value };
|
||||||
|
setEdit(v); onDirty(b.id, v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(selectableListTypes ?? []).map((t: any) => (<option key={t.id} value={t.id}>{t.name}</option>))}
|
||||||
|
<option value="__add__">+ 새 타입 추가…</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
<select className="h-9 rounded-md border border-neutral-300 px-2 text-sm" value={edit.readLevel} onChange={(e) => { const v = { ...edit, readLevel: e.target.value }; setEdit(v); onDirty(b.id, v); }}>
|
<select className="h-9 rounded-md border border-neutral-300 px-2 text-sm" value={edit.readLevel} onChange={(e) => { const v = { ...edit, readLevel: e.target.value }; setEdit(v); onDirty(b.id, v); }}>
|
||||||
<option value="public">public</option>
|
<option value="public">public</option>
|
||||||
@@ -167,201 +508,61 @@ function BoardRowCells({ b, onDirty }: { b: any; onDirty: (id: string, draft: an
|
|||||||
</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"><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 ? (
|
||||||
<td className="px-3 py-2 text-center">
|
<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); }}>
|
<select className="h-9 rounded-md border border-neutral-300 px-2 text-sm" defaultValue="" onChange={(e) => { if (e.target.value) onMove(e.target.value); }}>
|
||||||
<option value="general">general</option>
|
<option value="" disabled>대분류 선택</option>
|
||||||
<option value="special">special</option>
|
{categories.map((c: any) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</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>
|
) : null}
|
||||||
<td className="px-3 py-2 text-center"><input className="h-9 w-20 rounded-md border border-neutral-300 px-2 text-sm" type="number" value={edit.sortOrder} onChange={(e) => { const v = { ...edit, sortOrder: Number(e.target.value) }; setEdit(v); onDirty(b.id, v); }} /></td>
|
<td className="px-3 py-2 text-center"><input type="checkbox" checked={(edit.status ?? 'active') !== 'hidden'} onChange={(e) => { const v = { ...edit, status: e.target.checked ? 'active' : 'hidden' }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<button type="button" className="h-8 px-3 rounded-md border border-red-300 text-sm text-red-700 hover:bg-red-50" onClick={onDelete}>삭제</button>
|
||||||
|
</td>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DraggableRow({ index, onMove, children }: { index: number; onMove: (from: number, to: number) => void; children: React.ReactNode }) {
|
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
|
<tr className="align-middle select-none">
|
||||||
draggable
|
<td className="px-2 py-2 w-8 text-center text-neutral-500">{index + 1}</td>
|
||||||
onDragStart={(e) => {
|
<td className="px-2 py-2 w-16 text-center">
|
||||||
e.dataTransfer.setData("text/plain", String(index));
|
<div className="inline-flex items-center gap-1">
|
||||||
e.dataTransfer.effectAllowed = "move";
|
<button
|
||||||
// 수직만 보이게: 실제 행을 고정 포지션으로 띄워 따라오게 함 + 전역 placeholder 등록
|
type="button"
|
||||||
const row = e.currentTarget as HTMLTableRowElement;
|
className="h-6 w-6 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
|
||||||
const rect = row.getBoundingClientRect();
|
aria-label="위로"
|
||||||
const table = row.closest('table') as HTMLElement | null;
|
onClick={() => onMoveIndex(-1)}
|
||||||
const tableRect = table?.getBoundingClientRect();
|
disabled={index === 0}
|
||||||
// placeholder로 자리를 유지 (전역으로 참조)
|
>↑</button>
|
||||||
const placeholder = document.createElement('tr');
|
<button
|
||||||
placeholder.style.height = `${rect.height}px`;
|
type="button"
|
||||||
(row.parentNode as HTMLElement).insertBefore(placeholder, row);
|
className="h-6 w-6 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
|
||||||
(window as any).__adminDnd = { placeholder, dragging: row, target: null, before: false, rAF: 0 };
|
aria-label="아래로"
|
||||||
// 행을 고정 배치
|
onClick={() => onMoveIndex(1)}
|
||||||
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));
|
disabled={index === (totalCount - 1)}
|
||||||
const offsetY = e.clientY - rect.top;
|
>↓</button>
|
||||||
row.style.position = 'fixed';
|
</div>
|
||||||
row.style.left = `${tableRect ? tableRect.left : rect.left}px`;
|
</td>
|
||||||
row.style.width = `${tableRect ? tableRect.width : rect.width}px`;
|
|
||||||
row.style.zIndex = '9999';
|
|
||||||
row.classList.add('bg-white');
|
|
||||||
const updatePos = (clientY: number) => {
|
|
||||||
const top = clamp(clientY - offsetY, (tableRect?.top ?? 0), (tableRect?.bottom ?? (rect.top + rect.height)) - rect.height);
|
|
||||||
row.style.top = `${top}px`;
|
|
||||||
};
|
|
||||||
updatePos(e.clientY);
|
|
||||||
// 기본 드래그 이미지는 투명 1x1로 숨김
|
|
||||||
const img = document.createElement('canvas');
|
|
||||||
img.width = 1; img.height = 1; const ctx = img.getContext('2d'); ctx?.clearRect(0,0,1,1);
|
|
||||||
e.dataTransfer.setDragImage(img, 0, 0);
|
|
||||||
const onDragOver = (ev: DragEvent) => {
|
|
||||||
if (typeof ev.clientY === 'number') updatePos(ev.clientY);
|
|
||||||
};
|
|
||||||
const cleanup = () => {
|
|
||||||
row.style.position = '';
|
|
||||||
row.style.left = '';
|
|
||||||
row.style.top = '';
|
|
||||||
row.style.width = '';
|
|
||||||
row.style.zIndex = '';
|
|
||||||
row.classList.remove('bg-white');
|
|
||||||
placeholder.remove();
|
|
||||||
window.removeEventListener('dragover', onDragOver, true);
|
|
||||||
window.removeEventListener('dragend', cleanup, true);
|
|
||||||
const st = (window as any).__adminDnd; if (st?.rAF) cancelAnimationFrame(st.rAF);
|
|
||||||
(window as any).__adminDnd = undefined;
|
|
||||||
};
|
|
||||||
window.addEventListener('dragover', onDragOver, true);
|
|
||||||
window.addEventListener('dragend', cleanup, true);
|
|
||||||
}}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
// 수직만 허용: 기본 동작만 유지하고 수평 제스처는 무시
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = 'move';
|
|
||||||
const current = e.currentTarget as HTMLTableRowElement;
|
|
||||||
const state = (window as any).__adminDnd || {};
|
|
||||||
const ph: HTMLElement | undefined = state.placeholder;
|
|
||||||
if (!ph || !current.parentElement) return;
|
|
||||||
const r = current.getBoundingClientRect();
|
|
||||||
const before = e.clientY < r.top + r.height / 2;
|
|
||||||
// 목표만 저장하고 DOM 조작은 프레임당 1회 수행
|
|
||||||
state.target = current;
|
|
||||||
state.before = before;
|
|
||||||
if (!state.rAF) {
|
|
||||||
state.rAF = requestAnimationFrame(() => {
|
|
||||||
const st = (window as any).__adminDnd || {};
|
|
||||||
if (!st.placeholder || !st.target || !st.target.parentElement) { st.rAF = 0; return; }
|
|
||||||
const parent = st.target.parentElement as HTMLElement;
|
|
||||||
const desiredNode = st.before ? st.target : (st.target.nextSibling as any);
|
|
||||||
if (desiredNode !== st.placeholder) {
|
|
||||||
parent.insertBefore(st.placeholder, desiredNode || null);
|
|
||||||
if (st.dragging) {
|
|
||||||
const pr = st.placeholder.getBoundingClientRect();
|
|
||||||
(st.dragging as HTMLElement).style.top = `${pr.top}px`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
st.rAF = 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const from = Number(e.dataTransfer.getData("text/plain"));
|
|
||||||
const state = (window as any).__adminDnd || {};
|
|
||||||
const ph: HTMLElement | undefined = state.placeholder;
|
|
||||||
let to = index;
|
|
||||||
if (ph && ph.parentElement) {
|
|
||||||
to = Array.from(ph.parentElement.children).indexOf(ph);
|
|
||||||
ph.remove();
|
|
||||||
}
|
|
||||||
if (!Number.isNaN(from) && from !== to) onMove(from, to);
|
|
||||||
}}
|
|
||||||
className="align-middle cursor-ns-resize select-none"
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryRow({ idx, g, onMove, onDirty }: { idx: number; g: any; onMove: (from: number, to: number) => void; onDirty: (payload: any) => 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 (
|
||||||
<li
|
<>
|
||||||
className="px-4 py-3 flex items-center gap-3 cursor-ns-resize select-none"
|
<div className="w-10" />
|
||||||
draggable
|
<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); }} />
|
||||||
onDragStart={(e) => {
|
<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); }} />
|
||||||
e.dataTransfer.setData("text/plain", String(idx));
|
|
||||||
e.dataTransfer.effectAllowed = "move";
|
|
||||||
const item = e.currentTarget as HTMLLIElement;
|
|
||||||
const rect = item.getBoundingClientRect();
|
|
||||||
const listRect = item.parentElement?.getBoundingClientRect();
|
|
||||||
// placeholder (전역 등록)
|
|
||||||
const placeholder = document.createElement('div');
|
|
||||||
placeholder.style.height = `${rect.height}px`;
|
|
||||||
placeholder.style.border = '1px dashed rgba(0,0,0,0.1)';
|
|
||||||
item.parentElement?.insertBefore(placeholder, item);
|
|
||||||
(window as any).__adminDnd = { placeholder, dragging: item, target: null, before: false, rAF: 0 };
|
|
||||||
// fix item position
|
|
||||||
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));
|
|
||||||
const offsetY = e.clientY - rect.top;
|
|
||||||
item.style.position = 'fixed';
|
|
||||||
item.style.left = `${listRect ? listRect.left : rect.left}px`;
|
|
||||||
item.style.width = `${listRect ? listRect.width : rect.width}px`;
|
|
||||||
item.style.zIndex = '9999';
|
|
||||||
const updatePos = (y: number) => {
|
|
||||||
const top = clamp(y - offsetY, (listRect?.top ?? 0), (listRect?.bottom ?? (rect.top + rect.height)) - rect.height);
|
|
||||||
item.style.top = `${top}px`;
|
|
||||||
};
|
|
||||||
updatePos(e.clientY);
|
|
||||||
// hide default drag image
|
|
||||||
const img = document.createElement('canvas'); img.width = 1; img.height = 1; e.dataTransfer.setDragImage(img, 0, 0);
|
|
||||||
const onDragOver = (ev: DragEvent) => { if (typeof ev.clientY === 'number') updatePos(ev.clientY); };
|
|
||||||
const cleanup = () => { const st = (window as any).__adminDnd; if (st?.rAF) cancelAnimationFrame(st.rAF); item.style.position=''; item.style.left=''; item.style.top=''; item.style.width=''; item.style.zIndex=''; placeholder.remove(); window.removeEventListener('dragover', onDragOver, true); window.removeEventListener('dragend', cleanup, true); (window as any).__adminDnd = undefined; };
|
|
||||||
window.addEventListener('dragover', onDragOver, true);
|
|
||||||
window.addEventListener('dragend', cleanup, true);
|
|
||||||
}}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = 'move';
|
|
||||||
const state = (window as any).__adminDnd || {};
|
|
||||||
const current = e.currentTarget as HTMLElement;
|
|
||||||
const r = current.getBoundingClientRect();
|
|
||||||
state.target = current;
|
|
||||||
state.before = e.clientY < r.top + r.height / 2;
|
|
||||||
if (!state.rAF) {
|
|
||||||
state.rAF = requestAnimationFrame(() => {
|
|
||||||
const st = (window as any).__adminDnd || {};
|
|
||||||
if (!st.placeholder || !st.target || !st.target.parentElement) { st.rAF = 0; return; }
|
|
||||||
const parent = st.target.parentElement as HTMLElement;
|
|
||||||
const desiredNode = st.before ? st.target : (st.target.nextSibling as any);
|
|
||||||
if (desiredNode !== st.placeholder) {
|
|
||||||
parent.insertBefore(st.placeholder, desiredNode || null);
|
|
||||||
if (st.dragging) {
|
|
||||||
const pr = st.placeholder.getBoundingClientRect();
|
|
||||||
(st.dragging as HTMLElement).style.top = `${pr.top}px`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
st.rAF = 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const from = Number(e.dataTransfer.getData("text/plain"));
|
|
||||||
const state = (window as any).__adminDnd || {};
|
|
||||||
const ph: HTMLElement | undefined = state.placeholder;
|
|
||||||
let to = idx;
|
|
||||||
if (ph && ph.parentElement) {
|
|
||||||
to = Array.from(ph.parentElement.children).indexOf(ph);
|
|
||||||
ph.remove();
|
|
||||||
}
|
|
||||||
if (!Number.isNaN(from) && from !== to) onMove(from, to);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="w-6 text-xs text-neutral-500">≡</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" />
|
||||||
</li>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
223
src/app/admin/mainpage-settings/page.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"use client";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||||
|
import { Modal } from "@/app/components/ui/Modal";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
export default function MainPageSettingsPage() {
|
||||||
|
const { show } = useToast();
|
||||||
|
const { data: settingsResp, mutate: mutateSettings } = useSWR<{ settings: any }>("/api/admin/mainpage-settings", fetcher);
|
||||||
|
const { data: boardsResp } = useSWR<{ boards: any[] }>("/api/admin/boards", fetcher);
|
||||||
|
const { data: catsResp } = useSWR<{ categories: any[] }>("/api/admin/categories", fetcher);
|
||||||
|
|
||||||
|
const settings = settingsResp?.settings ?? {};
|
||||||
|
const boards = boardsResp?.boards ?? [];
|
||||||
|
const categories = catsResp?.categories ?? [];
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showBoardModal, setShowBoardModal] = useState(false);
|
||||||
|
const [draft, setDraft] = useState({
|
||||||
|
showBanner: settings.showBanner ?? true,
|
||||||
|
showPartnerShops: settings.showPartnerShops ?? true,
|
||||||
|
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 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingsResp?.settings) {
|
||||||
|
setDraft({
|
||||||
|
showBanner: settings.showBanner ?? true,
|
||||||
|
showPartnerShops: settings.showPartnerShops ?? true,
|
||||||
|
visibleBoardIds: settings.visibleBoardIds ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [settingsResp, settings]);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/mainpage-settings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(draft),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("save_failed");
|
||||||
|
const data = await res.json();
|
||||||
|
// 저장된 설정으로 즉시 업데이트
|
||||||
|
await mutateSettings({ settings: data.settings }, { revalidate: false });
|
||||||
|
show("저장되었습니다.");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
show("저장 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl md:text-2xl font-bold text-neutral-900">메인페이지 설정</h1>
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving}
|
||||||
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? "저장 중..." : "저장"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 배너 표시 */}
|
||||||
|
<div className="rounded-xl border border-neutral-200 bg-white p-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.showBanner}
|
||||||
|
onChange={(e) => setDraft({ ...draft, showBanner: e.target.checked })}
|
||||||
|
/>
|
||||||
|
메인 배너 표시
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 제휴 샾 목록 표시 */}
|
||||||
|
<div className="rounded-xl border border-neutral-200 bg-white p-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.showPartnerShops}
|
||||||
|
onChange={(e) => setDraft({ ...draft, showPartnerShops: e.target.checked })}
|
||||||
|
/>
|
||||||
|
제휴 샾 목록 표시
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 보일 게시판 선택 */}
|
||||||
|
<div className="rounded-xl border border-neutral-200 bg-white p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<label className="block text-sm font-medium">보일 게시판 선택</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowBoardModal(true)}
|
||||||
|
className="h-8 px-3 rounded-md border border-neutral-300 text-sm hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 min-h-[60px]">
|
||||||
|
{draft.visibleBoardIds.length === 0 ? (
|
||||||
|
<p className="text-sm text-neutral-400 py-4 text-center">선택된 게시판이 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
draft.visibleBoardIds.map((boardId: string, idx: number) => {
|
||||||
|
const board = boards.find((b: any) => b.id === boardId);
|
||||||
|
if (!board) return null;
|
||||||
|
const category = categories.find((c: any) => c.id === board.categoryId);
|
||||||
|
return (
|
||||||
|
<div key={boardId} className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-md">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{board.name}</span>
|
||||||
|
{category && (
|
||||||
|
<span className="text-xs text-neutral-500">({category.name})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => moveVisibleBoard(idx, -1)}
|
||||||
|
disabled={idx === 0}
|
||||||
|
className="h-7 px-2 rounded-md border border-neutral-300 text-xs disabled:opacity-40"
|
||||||
|
aria-label="위로"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 게시판 선택 모달 */}
|
||||||
|
<Modal open={showBoardModal} onClose={() => setShowBoardModal(false)}>
|
||||||
|
<div className="w-[600px] max-w-[90vw] max-h-[80vh] overflow-y-auto">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-lg font-bold mb-4">게시판 선택</h2>
|
||||||
|
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||||
|
{boards
|
||||||
|
.filter((b: any) => !draft.visibleBoardIds.includes(b.id))
|
||||||
|
.map((b: any) => {
|
||||||
|
const category = categories.find((c: any) => c.id === b.categoryId);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={b.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDraft({ ...draft, visibleBoardIds: [...draft.visibleBoardIds, b.id] });
|
||||||
|
setShowBoardModal(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-3 py-2 rounded-md border border-neutral-200 hover:bg-neutral-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{b.name}</span>
|
||||||
|
{category && (
|
||||||
|
<span className="text-xs text-neutral-500">({category.name})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{boards.filter((b: any) => !draft.visibleBoardIds.includes(b.id)).length === 0 && (
|
||||||
|
<p className="text-sm text-neutral-400 py-4 text-center">추가할 수 있는 게시판이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowBoardModal(false)}
|
||||||
|
className="h-9 px-4 rounded-md border border-neutral-300 text-sm hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
45
src/app/admin/partner-shops/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
373
src/app/admin/partners/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,37 +1,82 @@
|
|||||||
"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>
|
||||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
||||||
<thead>
|
<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>
|
<tr>
|
||||||
<th>닉네임</th>
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">ID</th>
|
||||||
<th>이름</th>
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">닉네임</th>
|
||||||
<th>전화</th>
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">이름</th>
|
||||||
<th>상태</th>
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">전화</th>
|
||||||
<th>권한</th>
|
<th className="px-4 py-2 text-right text-[12px] text-[#8c8c8c]">포인트</th>
|
||||||
<th></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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="divide-y divide-[#ececec] bg-white">
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<Row key={u.userId} u={u} onChanged={mutate} />
|
<Row key={u.userId} u={u} onChanged={mutate} />
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"]) {
|
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) {
|
||||||
@@ -18,4 +18,11 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
|
|||||||
return NextResponse.json({ board: updated });
|
return NextResponse.json({ board: updated });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
// Soft delete: mark as archived instead of physical deletion
|
||||||
|
const updated = await prisma.board.update({ where: { id }, data: { status: 'archived' } });
|
||||||
|
return NextResponse.json({ board: updated });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
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";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const boards = await prisma.board.findMany({
|
const boards = await prisma.board.findMany({
|
||||||
|
where: { NOT: { status: 'archived' } },
|
||||||
orderBy: { sortOrder: "asc" },
|
orderBy: { sortOrder: "asc" },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -14,14 +17,52 @@ 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,
|
||||||
|
listViewTypeId: true,
|
||||||
category: { select: { id: true, name: true, slug: true } },
|
category: { select: { id: true, name: true, slug: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json({ boards });
|
return NextResponse.json({ boards });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
slug: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
sortOrder: z.coerce.number().int().optional(),
|
||||||
|
readLevel: z.string().optional(),
|
||||||
|
writeLevel: z.string().optional(),
|
||||||
|
allowAnonymousPost: z.boolean().optional(),
|
||||||
|
allowSecretComment: z.boolean().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
isAdultOnly: z.boolean().optional(),
|
||||||
|
categoryId: z.string().nullable().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 data = parsed.data;
|
||||||
|
// sortOrder 기본: 같은 카테고리 내 마지막 다음 순서
|
||||||
|
let sortOrder = data.sortOrder;
|
||||||
|
if (!sortOrder) {
|
||||||
|
const max = await prisma.board.aggregate({
|
||||||
|
_max: { sortOrder: true },
|
||||||
|
where: { categoryId: data.categoryId ?? undefined },
|
||||||
|
});
|
||||||
|
sortOrder = (max._max.sortOrder ?? 0) + 1;
|
||||||
|
}
|
||||||
|
const { categoryId, sortOrder: _ignored, ...rest } = data;
|
||||||
|
const createData: Prisma.BoardCreateInput = {
|
||||||
|
...(rest as any),
|
||||||
|
sortOrder,
|
||||||
|
...(categoryId ? { category: { connect: { id: categoryId } } } : {}),
|
||||||
|
};
|
||||||
|
const created = await prisma.board.create({ data: createData });
|
||||||
|
return NextResponse.json({ board: created }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { getUserIdFromRequest } from "@/lib/auth";
|
|
||||||
import { requirePermission } from "@/lib/rbac";
|
|
||||||
|
|
||||||
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const userId = getUserIdFromRequest(req);
|
|
||||||
await requirePermission({ userId, resource: "ADMIN", action: "MODERATE" });
|
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const data: any = {};
|
const data: any = {};
|
||||||
for (const k of ["name", "slug", "sortOrder", "status"]) {
|
for (const k of ["name", "slug", "sortOrder", "status"]) {
|
||||||
@@ -18,8 +14,6 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
|
|||||||
|
|
||||||
export async function DELETE(req: Request, context: { params: Promise<{ id: string }> }) {
|
export async function DELETE(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const userId = getUserIdFromRequest(req);
|
|
||||||
await requirePermission({ userId, resource: "ADMIN", action: "MODERATE" });
|
|
||||||
await prisma.boardCategory.delete({ where: { id } });
|
await prisma.boardCategory.delete({ where: { id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +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 { getUserIdFromRequest } from "@/lib/auth";
|
import type { Prisma } from "@prisma/client";
|
||||||
import { requirePermission } from "@/lib/rbac";
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const categories = await prisma.boardCategory.findMany({
|
const categories = await prisma.boardCategory.findMany({
|
||||||
@@ -19,12 +18,10 @@ const createSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const userId = getUserIdFromRequest(req);
|
|
||||||
await requirePermission({ userId, resource: "ADMIN", action: "MODERATE" });
|
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
src/app/api/admin/mainpage-settings/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
const SETTINGS_KEY = "mainpage_settings";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const setting = await prisma.setting.findUnique({
|
||||||
|
where: { key: SETTINGS_KEY },
|
||||||
|
});
|
||||||
|
const settings = setting ? JSON.parse(setting.value) : {};
|
||||||
|
return NextResponse.json({ settings }, { headers: { "Cache-Control": "no-store" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const value = JSON.stringify(body);
|
||||||
|
const updated = await prisma.setting.upsert({
|
||||||
|
where: { key: SETTINGS_KEY },
|
||||||
|
update: { value },
|
||||||
|
create: { key: SETTINGS_KEY, value },
|
||||||
|
});
|
||||||
|
const settings = JSON.parse(updated.value);
|
||||||
|
return NextResponse.json({ settings, ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
28
src/app/api/admin/partner-categories/[id]/route.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const data: any = {};
|
||||||
|
for (const k of ["name", "sortOrder"]) if (k in body) data[k] = body[k];
|
||||||
|
try {
|
||||||
|
const category = await prisma.partnerCategory.update({ where: { id }, data });
|
||||||
|
return NextResponse.json({ category });
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === 'P2002') return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 카테고리입니다.' }, { status: 409 });
|
||||||
|
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
try {
|
||||||
|
await prisma.partnerCategory.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json({ error: 'category_in_use', message: '해당 카테고리를 사용하는 제휴업체가 있습니다.' }, { status: 409 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
25
src/app/api/admin/partner-categories/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const categories = await prisma.partnerCategory.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }] });
|
||||||
|
return NextResponse.json({ categories });
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSchema = z.object({ name: z.string().min(1), sortOrder: z.coerce.number().int().optional() });
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const parsed = createSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
|
try {
|
||||||
|
const category = await prisma.partnerCategory.create({ data: { name: parsed.data.name, sortOrder: parsed.data.sortOrder ?? 0 } });
|
||||||
|
return NextResponse.json({ category }, { status: 201 });
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === 'P2002') return NextResponse.json({ error: 'duplicate_name', message: '이미 존재하는 카테고리입니다.' }, { status: 409 });
|
||||||
|
return NextResponse.json({ error: 'unknown_error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
21
src/app/api/admin/partner-shops/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||