Compare commits

..

79 Commits

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

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

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

View File

@@ -0,0 +1,6 @@
### 일반 게시판 작업
* 게시판은 일반게시판과 특수게시판 여러 종류로 나뉨
1. 일반 게시판 작업
1-1. 일반게시판은 상단에 메인과 같은 배너가 있음
1-2 최신순, 조회순, 좋아요순, 댓글 순으로 정렬 가능

View 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는 검색어를 입력해 주세요.

View File

@@ -1,6 +1,10 @@
git worktree add ../MainPage -b MainPage
MainPage
AdminPage
BoardContentList
BoardContentWrite
BoardContentView
커밋 테스트

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

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

View File

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

1
.gitignore vendored
View File

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

13
.runner Normal file
View File

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

6
deploytest.txt Normal file
View File

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

18
ecosystem.config.js Normal file
View File

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

3
middleware.ts Normal file
View File

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

View File

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

View 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");

View 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");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,10 +5,12 @@ generator client {
provider = "prisma-client-js"
}
/*
generator erd {
provider = "prisma-erd-generator"
output = "../public/erd.svg"
}
*/
datasource db {
provider = "sqlite"
@@ -22,10 +24,6 @@ enum BoardStatus {
archived
}
enum BoardType {
general
special
}
enum AccessLevel {
public // 비회원도 접근
@@ -126,8 +124,6 @@ model Board {
description String?
sortOrder Int @default(0)
status BoardStatus @default(active)
type BoardType @default(general) // 일반/특수
requiresApproval Boolean @default(false) // 게시물 승인 필요 여부
allowAnonymousPost Boolean @default(false) // 익명 글 허용
allowSecretComment Boolean @default(false) // 비밀댓글 허용
isAdultOnly Boolean @default(false) // 성인 인증 필요 여부
@@ -141,6 +137,12 @@ model Board {
categoryId String?
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[]
moderators BoardModerator[]
@@ -148,11 +150,30 @@ model Board {
updatedAt DateTime @updatedAt
@@index([status, sortOrder])
@@index([type, requiresApproval])
@@index([categoryId])
@@index([mainPageViewTypeId])
@@index([listViewTypeId])
@@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 {
id String @id @default(cuid())
@@ -196,6 +217,7 @@ model Post {
reports Report[]
stat PostStat?
viewLogs PostViewLog[]
dailyViews DailyPostView[]
@@index([boardId, status, createdAt])
@@index([boardId, isPinned, pinnedOrder])
@@ -204,13 +226,17 @@ model Post {
// 사용자
model User {
userId String @id @default(cuid())
userId String @id
nickname String @unique
passwordHash String?
name String
birth DateTime
phone String @unique
birth DateTime?
phone String? @unique
rank Int @default(0)
// 누적 포인트, 레벨, 등급(0~10)
points Int @default(0)
level Int @default(1)
grade Int @default(0)
status UserStatus @default(active)
authLevel AuthLevel @default(USER)
@@ -234,6 +260,7 @@ model User {
blocksInitiated Block[] @relation("Blocker")
blocksReceived Block[] @relation("Blocked")
pointTxns PointTransaction[]
attendances Attendance[]
sanctions Sanction[]
nicknameChanges NicknameChange[]
passwordResetTokens PasswordResetToken[] @relation("PasswordResetUser")
@@ -299,6 +326,8 @@ model UserRole {
model Comment {
id String @id @default(cuid())
postId String
parentId String? // 부모 댓글 ID (null이면 최상위 댓글)
depth Int @default(0) // 댓글 깊이 (0=최상위, 1=1단계 대댓글, 2=2단계 대댓글)
authorId String?
content String
isAnonymous Boolean @default(false)
@@ -308,10 +337,13 @@ model Comment {
updatedAt DateTime @updatedAt
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
replies Comment[] @relation("CommentReplies")
author User? @relation(fields: [authorId], references: [userId], onDelete: SetNull)
reports Report[]
@@index([postId, createdAt])
@@index([parentId])
@@map("comments")
}
@@ -407,6 +439,21 @@ model PostStat {
@@map("post_stats")
}
// 일일 게시글 조회수 (날짜별 집계)
model DailyPostView {
id String @id @default(cuid())
postId String
date DateTime // 날짜만 사용 (시간은 00:00:00)
viewCount Int @default(0)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
@@unique([postId, date])
@@index([date, viewCount])
@@index([postId, date])
@@map("daily_post_views")
}
// 신고(게시글/댓글 대상)
model Report {
id String @id @default(cuid())
@@ -476,6 +523,20 @@ model PointTransaction {
@@map("point_transactions")
}
// 출석부 기록 (사용자별 일자 단위 출석)
model Attendance {
id String @id @default(cuid())
userId String
date DateTime // 자정 기준 날짜만 사용
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [userId], onDelete: Cascade)
@@unique([userId, date])
@@index([userId, date])
@@map("attendance")
}
// 레벨 임계치(선택)
model LevelThreshold {
id String @id @default(cuid())
@@ -644,16 +705,36 @@ model Partner {
id String @id @default(cuid())
name String @unique
category String
categoryId String?
latitude Float
longitude Float
address String?
imageUrl String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categoryRef PartnerCategory? @relation(fields: [categoryId], references: [id])
@@index([category])
@@index([categoryId])
@@index([sortOrder])
@@map("partners")
}
// 제휴업체 카테고리(관리자 생성/삭제)
model PartnerCategory {
id String @id @default(cuid())
name String @unique
sortOrder Int @default(0)
createdAt DateTime @default(now())
partners Partner[]
@@index([sortOrder])
@@map("partner_categories")
}
// 배너/공지 노출용
model Banner {
id String @id @default(cuid())
@@ -696,6 +777,10 @@ model PartnerRequest {
longitude Float
address String?
contact String?
region String?
imageUrl String?
sortOrder Int @default(0)
active Boolean @default(true)
status String @default("pending") // pending/approved/rejected
createdAt DateTime @default(now())
approvedAt DateTime?
@@ -703,3 +788,17 @@ model PartnerRequest {
@@index([status, createdAt])
@@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로 통합)

View File

@@ -1,17 +1,185 @@
const { PrismaClient } = require("@prisma/client");
const { createHash } = require("crypto");
const prisma = new PrismaClient();
function hashPassword(plain) {
return createHash("sha256").update(plain, "utf8").digest("hex");
}
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
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() {
// 카테고리 트리 (projectmemo 기준 상위 그룹)
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: "nearby-partners", 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" },
{ name: "소통방", slug: "community", sortOrder: 3, status: "active" },
];
const map = {};
for (const c of categories) {
@@ -84,14 +252,24 @@ async function upsertRoles() {
async function upsertAdmin() {
const admin = await prisma.user.upsert({
where: { nickname: "admin" },
update: {},
update: {
passwordHash: hashPassword("12341234"),
grade: 7,
points: 1650000,
level: 200,
},
create: {
userId: "admin",
nickname: "admin",
name: "Administrator",
birth: new Date("1990-01-01"),
phone: "010-0000-0001",
passwordHash: hashPassword("12341234"),
agreementTermsAt: new Date(),
authLevel: "ADMIN",
grade: 7,
points: 1650000,
level: 200,
},
});
@@ -108,34 +286,82 @@ async function upsertAdmin() {
return admin;
}
async function seedAdminAttendance(admin) {
try {
const now = new Date();
const year = now.getUTCFullYear();
const days = [1, 2, 5, 6]; // 11월 1,2,5,6일
for (const d of days) {
const date = new Date(Date.UTC(year, 10, d, 0, 0, 0, 0)); // 10 = November (0-based)
// @@unique([userId, date]) 기준으로 업서트
await prisma.attendance.upsert({
where: { userId_date: { userId: admin.userId, date } },
update: {},
create: { userId: admin.userId, date },
});
}
} catch (e) {
console.warn("seedAdminAttendance failed:", e);
}
}
async function seedRandomAttendanceForUsers(users, minDays = 10, maxDays = 20) {
try {
const today = new Date();
const todayUtcMidnight = new Date(Date.UTC(
today.getUTCFullYear(),
today.getUTCMonth(),
today.getUTCDate(),
0, 0, 0, 0
));
for (const user of users) {
const count = randomInt(minDays, maxDays);
const used = new Set();
while (used.size < count) {
const offsetDays = randomInt(0, 120); // 최근 120일 범위에서 랜덤
const date = new Date(todayUtcMidnight.getTime() - offsetDays * 24 * 60 * 60 * 1000);
const key = date.toISOString().slice(0, 10); // YYYY-MM-DD
if (used.has(key)) continue;
used.add(key);
await prisma.attendance.upsert({
where: { userId_date: { userId: user.userId, date } },
update: {},
create: { userId: user.userId, date },
});
}
}
} catch (e) {
console.warn("seedRandomAttendanceForUsers failed:", e);
}
}
async function upsertBoards(admin, categoryMap) {
const boards = [
// 일반
{ name: "공지사항", slug: "notice", description: "공지", type: "general", sortOrder: 1, writeLevel: "moderator" },
{ name: "가입인사", slug: "greetings", description: "가입인사", type: "general", sortOrder: 2 },
{ name: "버그건의", slug: "bug-report", description: "버그/건의", type: "general", sortOrder: 3 },
{ name: "이벤트", slug: "event", description: "이벤트", type: "general", sortOrder: 4, requiredTags: { required: ["이벤트"] } },
{ name: "자유게시판", slug: "free", description: "자유", type: "general", sortOrder: 5 },
{ name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", type: "general", sortOrder: 6 },
{ name: "마사지꿀팁", slug: "tips", description: "", type: "general", sortOrder: 7 },
{ name: "익명게시판", slug: "anonymous", description: "익명", type: "general", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true },
{ 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: "notice", description: "공지", sortOrder: 1, writeLevel: "moderator" },
{ name: "가입인사", slug: "greetings", description: "가입인사", sortOrder: 2 },
{ name: "버그건의", slug: "bug-report", description: "버그/건의", sortOrder: 3 },
{ name: "이벤트", slug: "event", description: "이벤트", sortOrder: 4, requiredTags: { required: ["이벤트"] } },
{ name: "자유게시판", slug: "free", description: "자유", sortOrder: 5 },
{ name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", sortOrder: 6 },
{ name: "익명게시판", slug: "anonymous", description: "익명", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true },
{ name: "청와대", slug: "blue-house", description: "레벨 제한", sortOrder: 10, readLevel: "member" },
// 특수
{ name: "출석부", slug: "attendance", description: "데일리 체크인", type: "special", sortOrder: 12 },
{ name: "주변 제휴업체", slug: "nearby-partners", description: "위치 기반", type: "special", sortOrder: 13 },
{ name: "회원랭킹", slug: "ranking", description: "랭킹", type: "special", sortOrder: 14 },
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", type: "special", sortOrder: 15 },
{ 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 } },
{ name: "출석부", slug: "attendance", description: "데일리 체크인", sortOrder: 12 },
{ name: "회원랭킹", slug: "ranking", description: "랭킹", sortOrder: 14 },
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", sortOrder: 15 },
{ name: "월간집계", slug: "monthly-stats", description: "월간 통계", sortOrder: 16 },
// 광고/제휴 계열 게시판은 제거(메인/명예의전당/소통방 외)
];
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) {
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
const mapBySlug = {
@@ -149,22 +375,12 @@ async function upsertBoards(admin, categoryMap) {
ranking: "hall-of-fame",
"free-coupons": "hall-of-fame",
"monthly-stats": "hall-of-fame",
// 주변 제휴업체
"nearby-partners": "nearby-partners",
// 제휴업소 정보
"partners-photos": "partner-info",
// 방문후기
reviews: "reviews",
// 소통방(기본값 community로 처리)
free: "community",
qna: "community",
tips: "community",
anonymous: "community",
"find-therapist": "community",
"blue-house": "community",
// 광고/제휴
"partner-contact": "ads-affiliates",
"partner-req": "ads-affiliates",
};
const categorySlug = mapBySlug[b.slug] || "community";
const category = categoryMap[categorySlug];
@@ -173,22 +389,30 @@ async function upsertBoards(admin, categoryMap) {
update: {
description: b.description,
sortOrder: b.sortOrder,
type: b.type,
requiresApproval: !!b.requiresApproval,
allowAnonymousPost: !!b.allowAnonymousPost,
readLevel: b.readLevel || undefined,
categoryId: category ? category.id : undefined,
// 기본은 텍스트, 'ranking'은 특수랭킹, 'attendance'는 특수출석으로 오버라이드
...(mainText ? { mainPageViewTypeId: mainText.id } : {}),
...(listText ? { listViewTypeId: listText.id } : {}),
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
},
create: {
name: b.name,
slug: b.slug,
description: b.description,
sortOrder: b.sortOrder,
type: b.type,
requiresApproval: !!b.requiresApproval,
allowAnonymousPost: !!b.allowAnonymousPost,
readLevel: b.readLevel || undefined,
categoryId: category ? category.id : undefined,
// 기본은 텍스트, 'ranking'은 특수랭킹, 'attendance'는 특수출석으로 오버라이드
...(mainText ? { mainPageViewTypeId: mainText.id } : {}),
...(listText ? { listViewTypeId: listText.id } : {}),
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
},
});
created.push(board);
@@ -207,6 +431,63 @@ async function upsertBoards(admin, categoryMap) {
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() {
// 금칙어 예시
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() {
console.log("DATABASE_URL:", process.env.DATABASE_URL);
try {
const tables = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table'`;
console.log("SQLite tables:", tables.map((t) => t.name || t.NAME || JSON.stringify(t)));
} catch {}
// SQLite 수동 보정: partner_categories 테이블과 partners.categoryId 컬럼 보장
try {
const rows = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table' AND name='partner_categories'`;
if (!Array.isArray(rows) || rows.length === 0) {
console.log("Creating missing table: partner_categories");
await prisma.$executeRawUnsafe(
"CREATE TABLE IF NOT EXISTS partner_categories (\n" +
"id TEXT PRIMARY KEY,\n" +
"name TEXT NOT NULL UNIQUE,\n" +
"sortOrder INTEGER NOT NULL DEFAULT 0,\n" +
"createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n" +
")"
);
await prisma.$executeRawUnsafe("CREATE INDEX IF NOT EXISTS idx_partner_categories_sortOrder ON partner_categories(sortOrder)");
}
// Attendance 테이블 보장 (마이그레이션 미실행 환경 대응)
const att = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table' AND name='attendance'`;
if (!Array.isArray(att) || att.length === 0) {
console.log("Creating missing table: attendance");
await prisma.$executeRawUnsafe(
"CREATE TABLE IF NOT EXISTS attendance (\n" +
"id TEXT PRIMARY KEY,\n" +
"userId TEXT NOT NULL,\n" +
"date DATETIME NOT NULL,\n" +
"createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n" +
")"
);
await prisma.$executeRawUnsafe("CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_user_date ON attendance(userId, date)");
await prisma.$executeRawUnsafe("CREATE INDEX IF NOT EXISTS idx_attendance_date ON attendance(date)");
}
const cols = await prisma.$queryRaw`PRAGMA table_info('partners')`;
const hasCategoryId = Array.isArray(cols) && cols.some((c) => (c.name || c.COLUMN_NAME) === 'categoryId');
if (!hasCategoryId) {
console.log("Adding missing column: partners.categoryId");
await prisma.$executeRawUnsafe("ALTER TABLE partners ADD COLUMN categoryId TEXT");
// 외래키 제약은 생략 (SQLite에서는 제약 추가가 까다로움)
}
// partner_requests 확장 컬럼 보장 (region, imageUrl, sortOrder, active)
const prCols = await prisma.$queryRaw`PRAGMA table_info('partner_requests')`;
const colHas = (name) => Array.isArray(prCols) && prCols.some((c) => (c.name || c.COLUMN_NAME) === name);
if (!colHas('region')) {
console.log("Adding missing column: partner_requests.region");
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN region TEXT");
}
if (!colHas('imageUrl')) {
console.log("Adding missing column: partner_requests.imageUrl");
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN imageUrl TEXT");
}
if (!colHas('sortOrder')) {
console.log("Adding missing column: partner_requests.sortOrder");
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN sortOrder INTEGER NOT NULL DEFAULT 0");
}
if (!colHas('active')) {
console.log("Adding missing column: partner_requests.active");
await prisma.$executeRawUnsafe("ALTER TABLE partner_requests ADD COLUMN active BOOLEAN NOT NULL DEFAULT 1");
}
} catch (e) {
console.warn("SQLite schema ensure failed:", e);
}
await upsertRoles();
const admin = await upsertAdmin();
const categoryMap = await upsertCategories();
await upsertViewTypes();
const randomUsers = await createRandomUsers(3);
await seedRandomAttendanceForUsers(randomUsers, 10, 20);
await removeNonPrimaryBoards();
const boards = await upsertBoards(admin, categoryMap);
// 샘플 글 하나
const free = boards.find((b) => b.slug === "free") || boards[0];
const post = await prisma.post.create({
data: {
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 seedAdminAttendance(admin);
await seedMainpageVisibleBoards(boards);
await createPostsForAllBoards(boards, 100, admin);
await seedPartnerShops();
await seedBanners();
await seedPolicies();
// 제휴업체 예시 데이터
const partners = [
{ name: "힐링마사지", category: "spa", latitude: 37.5665, longitude: 126.9780, address: "서울 구" },
{ name: "웰빙테라피", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
{ name: "test", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
{ name: "굿짐", category: "gym", latitude: 37.5600, longitude: 126.9820, address: "서울 중구" },
];
// 파트너 카테고리(PartnerCategory) 생성 및 매핑
const partnerCategoryNames = Array.from(new Set(partners.map((p) => p.category).filter(Boolean)));
const partnerCategoryMap = {};
for (let i = 0; i < partnerCategoryNames.length; i++) {
const name = partnerCategoryNames[i];
const created = await prisma.partnerCategory.upsert({
where: { name },
update: { sortOrder: i + 1 },
create: { name, sortOrder: i + 1 },
});
partnerCategoryMap[name] = created;
}
for (const p of partners) {
await prisma.partner.upsert({ where: { name: p.name }, update: p, create: p });
const categoryRef = p.category ? partnerCategoryMap[p.category] : null;
const data = { ...p, categoryId: categoryRef ? categoryRef.id : null };
await prisma.partner.upsert({ where: { name: p.name }, update: data, create: data });
}
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 795 KiB

After

Width:  |  Height:  |  Size: 836 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 99 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 69 KiB

9
public/svgs/03_gold.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 70 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 92 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 95 KiB

9
public/svgs/08_god.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View File

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

View File

@@ -1,6 +1,7 @@
"use client";
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());
@@ -8,21 +9,112 @@ export default function AdminBannersPage() {
const { data, mutate } = useSWR<{ banners: any[] }>("/api/admin/banners", fetcher);
const banners = data?.banners ?? [];
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() {
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 (
<div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
<h1> </h1>
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
<input placeholder="제목" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
<input placeholder="이미지 URL" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
<input placeholder="링크 URL(선택)" value={form.linkUrl} onChange={(e) => setForm({ ...form, linkUrl: e.target.value })} />
<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
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.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 }}>
{banners.map((b) => (
<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 style={{ fontSize: 12, opacity: 0.7 }}> {b.sortOrder} · {b.active ? "활성" : "비활성"}</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: "DELETE" }); mutate(); }}></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(); }} 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>
))}
</ul>

View File

@@ -1,14 +1,52 @@
"use client";
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());
export default function AdminBoardsPage() {
const { show } = useToast();
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 boards = boardsResp?.boards ?? [];
const categories = (catsResp?.categories ?? []).sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
const rawBoards = boardsResp?.boards ?? [];
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 map: Record<string, any[]> = {};
for (const b of boards) {
@@ -16,13 +54,34 @@ export default function AdminBoardsPage() {
if (!map[cid]) map[cid] = [];
map[cid].push(b);
}
return categories.map((c: any) => ({ ...c, items: (map[c.id] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) }));
}, [boards, categories]);
const result = orderedCats.map((c: any) => {
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>>({});
const [dirtyCats, setDirtyCats] = useState<Record<string, any>>({});
const [savingAll, setSavingAll] = useState(false);
// 최초/데이터 변경 시 표시용 카테고리 순서를 초기화
// 서버 sortOrder에 맞춰 초기 catOrder 설정
// categories가 바뀔 때만 동기화
// 사용자가 드래그로 순서를 바꾸면 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) {
setSavingId(b.id);
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();
}
// DnD: 카테고리 순서 변경
async function reorderCategories(next: any[]) {
// optimistic update
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 }) })));
mutateCats();
// 버튼으로 카테고리 순서 이동 (↑/↓)
function moveCategory(catId: string, delta: number) {
const baseIds = (catOrder.length ? catOrder : categories.map((c: any) => c.id));
const idx = baseIds.indexOf(catId);
if (idx === -1) return;
const to = idx + delta;
if (to < 0 || to >= baseIds.length) return;
const nextIds = [...baseIds];
const [moved] = nextIds.splice(idx, 1);
nextIds.splice(to, 0, moved);
setCatOrder(nextIds);
const nextCats = nextIds.map((id) => categories.find((c: any) => c.id === id)).filter(Boolean) as any[];
reorderCategories(nextCats);
}
// DnD: 보드 순서 변경 (카테고리 내부)
async function reorderBoards(categoryId: string, nextItems: 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 }) })));
mutateBoards();
// DnD: 카테고리 순서 변경 (저장 시 반영)
function reorderCategories(next: any[]) {
setDirtyCats((prev) => {
// 서버 기준(또는 이전 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) {
@@ -49,106 +165,331 @@ export default function AdminBoardsPage() {
function markCatDirty(id: string, draft: any) {
setDirtyCats((prev) => ({ ...prev, [id]: draft }));
}
function toggleCat(id: string) {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
}
async function saveAll() {
const prevDirtyBoards = dirtyBoards;
const prevDirtyCats = dirtyCats;
try {
setSavingAll(true);
const boardEntries = Object.entries(dirtyBoards);
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) })),
...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({});
setDirtyCats({});
mutateBoards();
mutateCats();
} catch (e) {
// 실패 시: 변경 사항 취소하고 서버 상태로 되돌림
setDirtyBoards({});
setDirtyCats({});
await Promise.all([
mutateBoards(undefined, { revalidate: true }),
mutateCats(undefined, { revalidate: true }),
]);
} finally {
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 (
<div className="space-y-6">
<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">
<button
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"
>{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 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"></div>
<ul className="divide-y divide-neutral-100">
{groups.map((g, idx) => (
<CategoryRow key={g.id} idx={idx} g={g} onMove={(from, to) => {
const arr = [...groups];
const [moved] = arr.splice(from, 1);
arr.splice(to, 0, moved);
reorderCategories(arr);
}} onDirty={(payload) => markCatDirty(g.id, { ...payload })} />
))}
</ul>
<button
type="button"
aria-label="toggle"
className="w-8 text-2xl leading-none text-neutral-700"
onClick={() => toggleCat(g.id)}
>{expanded[g.id] ? '▾' : '▸'}</button>
{g.id !== 'uncat' && (
<>
<label className="ml-auto flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
checked={(g.status ?? 'active') !== 'hidden'}
onChange={(e) => markCatDirty(g.id, { status: e.target.checked ? 'active' : 'hidden' })}
/>
</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>
{groups.map((g) => (
<section key={g.id} className="rounded-xl border border-neutral-200 overflow-hidden bg-white">
<div className="px-4 py-2 border-b border-neutral-200 flex items-center justify-between">
<div className="text-sm font-semibold">: {g.name}</div>
<div className="text-xs text-neutral-500">slug: {g.slug}</div>
{expanded[g.id] && (
<div className="overflow-x-auto border-t border-neutral-100 ml-8">
<div className="flex items-center justify-end p-2">
<button
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 className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="text-xs text-neutral-500 border-b border-neutral-200">
<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">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>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{g.items.map((b, i) => (
<DraggableRow
key={b.id}
catId={g.id}
boardId={b.id}
index={i}
onMove={(from, to) => {
const list = [...g.items];
const [mv] = list.splice(from, 1);
list.splice(to, 0, mv);
reorderBoards(g.id, list);
totalCount={g.items.length}
onMoveIndex={(delta) => {
const ids = (boardOrderByCat[g.id]?.length ? boardOrderByCat[g.id] : g.items.map((x: any) => x.id));
const from = i;
const to = from + delta;
if (to < 0 || to >= ids.length) return;
const next = [...ids];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
setBoardOrderByCat((prev) => ({ ...prev, [g.id]: next }));
const nextItems = next.map((id) => g.items.find((x: any) => x.id === id)).filter(Boolean) as any[];
reorderBoards(g.id, nextItems);
}}
>
<BoardRowCells 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>
))}
</tbody>
</table>
</div>
</section>
)}
</li>
))}
</ul>
</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 selectableMainTypes = (mainTypes ?? []).filter((t: any) => t.id !== defaultMainTypeId);
const selectableListTypes = (listTypes ?? []).filter((t: any) => t.id !== defaultListTypeId);
// 표시 값: 현재 값이 선택 가능 목록에 없으면 첫 번째 항목을 사용
const effectiveMainTypeId = selectableMainTypes.some((t: any) => t.id === edit.mainPageViewTypeId)
? edit.mainPageViewTypeId
: (selectableMainTypes[0]?.id ?? '');
const effectiveListTypeId = selectableListTypes.some((t: any) => t.id === edit.listViewTypeId)
? edit.listViewTypeId
: (selectableListTypes[0]?.id ?? '');
return (
<>
<td className="px-3 py-2"><input className="h-9 w-full rounded-md border border-neutral-300 px-2 text-sm" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2"><input className="h-9 w-full rounded-md border border-neutral-300 px-2 text-sm" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2"><input className="h-9 w-full min-w-[160px] rounded-md border border-neutral-300 px-2 text-sm" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2"><input className="h-9 w-full min-w-[200px] rounded-md border border-neutral-300 px-2 text-sm" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center">
<select
className="h-9 rounded-md border border-neutral-300 px-2 text-sm"
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">
<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>
@@ -167,201 +508,61 @@ function BoardRowCells({ b, onDirty }: { b: any; onDirty: (id: string, draft: an
</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.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">
<select className="h-9 rounded-md border border-neutral-300 px-2 text-sm" value={edit.type} onChange={(e) => { const v = { ...edit, type: e.target.value }; setEdit(v); onDirty(b.id, v); }}>
<option value="general">general</option>
<option value="special">special</option>
<select 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="" disabled> </option>
{categories.map((c: any) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => { const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center"><input 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>
) : null}
<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 (
<tr
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", String(index));
e.dataTransfer.effectAllowed = "move";
// 수직만 보이게: 실제 행을 고정 포지션으로 띄워 따라오게 함 + 전역 placeholder 등록
const row = e.currentTarget as HTMLTableRowElement;
const rect = row.getBoundingClientRect();
const table = row.closest('table') as HTMLElement | null;
const tableRect = table?.getBoundingClientRect();
// placeholder로 자리를 유지 (전역으로 참조)
const placeholder = document.createElement('tr');
placeholder.style.height = `${rect.height}px`;
(row.parentNode as HTMLElement).insertBefore(placeholder, row);
(window as any).__adminDnd = { placeholder, dragging: row, target: null, before: false, rAF: 0 };
// 행을 고정 배치
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));
const offsetY = e.clientY - rect.top;
row.style.position = 'fixed';
row.style.left = `${tableRect ? tableRect.left : rect.left}px`;
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"
>
<tr className="align-middle select-none">
<td className="px-2 py-2 w-8 text-center text-neutral-500">{index + 1}</td>
<td className="px-2 py-2 w-16 text-center">
<div className="inline-flex items-center gap-1">
<button
type="button"
className="h-6 w-6 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
aria-label="위로"
onClick={() => onMoveIndex(-1)}
disabled={index === 0}
></button>
<button
type="button"
className="h-6 w-6 rounded-md border border-neutral-300 text-[12px] disabled:opacity-40"
aria-label="아래로"
onClick={() => onMoveIndex(1)}
disabled={index === (totalCount - 1)}
></button>
</div>
</td>
{children}
</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 });
return (
<li
className="px-4 py-3 flex items-center gap-3 cursor-ns-resize select-none"
draggable
onDragStart={(e) => {
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="w-10" />
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48 min-w-[160px]" value={edit.name} onChange={(e) => { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} />
<input className="h-8 rounded-md border border-neutral-300 px-2 text-sm w-48 min-w-[200px]" value={edit.slug} onChange={(e) => { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
<div className="flex-1" />
</li>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,37 +1,82 @@
"use client";
import useSWR from "swr";
import { useState } from "react";
import { useMemo, useState } from "react";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function AdminUsersPage() {
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 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 (
<div>
<h1> </h1>
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
<input placeholder="검색(nickname/phone/name)" value={q} onChange={(e) => setQ(e.target.value)} />
<div className="mb-3 rounded-xl border border-neutral-300 bg-white p-3 flex items-center gap-2">
<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>
<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>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">ID</th>
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]"></th>
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]"></th>
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]"></th>
<th className="px-4 py-2 text-right text-[12px] text-[#8c8c8c]"></th>
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]"></th>
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]"></th>
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]"></th>
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]"></th>
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]"></th>
</tr>
</thead>
<tbody>
<tbody className="divide-y divide-[#ececec] bg-white">
{users.map((u) => (
<Row key={u.userId} u={u} onChanged={mutate} />
))}
</tbody>
</table>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 12 }}>
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}></button>
<span style={{ fontSize: 12 }}> {page} / {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}></button>
<span style={{ marginLeft: 12, fontSize: 12 }}> </span>
<select value={pageSize} onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(1); }}>
{[10, 20, 50, 100].map((s) => (<option key={s} value={s}>{s}</option>))}
</select>
</div>
</div>
);
}
@@ -45,25 +90,31 @@ function Row({ u, onChanged }: { u: any; onChanged: () => void }) {
}
const allRoles = ["admin", "editor", "user"] as const;
return (
<tr>
<td>{u.nickname}</td>
<td>{u.name}</td>
<td>{u.phone}</td>
<td>
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="active">active</option>
<option value="suspended">suspended</option>
<option value="withdrawn">withdrawn</option>
<tr className="hover:bg-neutral-50">
<td className="px-4 py-2 text-left tabular-nums">{u.userId}</td>
<td className="px-4 py-2 text-left">{u.nickname}</td>
<td className="px-4 py-2 text-left">{u.name}</td>
<td className="px-4 py-2 text-left">{u.phone}</td>
<td className="px-4 py-2 text-right tabular-nums">{(u.points ?? 0).toLocaleString()}</td>
<td className="px-4 py-2 text-center">{u.level ?? 1}</td>
<td className="px-4 py-2 text-center">{u.grade ?? 0}</td>
<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>
</td>
<td>
<td className="px-4 py-2">
{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}
</label>
))}
</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>
);
}

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import type { Prisma } from "@prisma/client";
import { z } from "zod";
export async function GET() {
@@ -9,8 +10,11 @@ export async function GET() {
const createSchema = z.object({
title: z.string().min(1),
imageUrl: z.string().url(),
linkUrl: z.string().url().optional(),
// 절대 URL 또는 /로 시작하는 상대경로 허용
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(),
sortOrder: z.coerce.number().int().optional(),
startAt: z.coerce.date().optional(),
@@ -21,7 +25,7 @@ export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const banner = await prisma.banner.create({ data: parsed.data });
const banner = await prisma.banner.create({ data: parsed.data as Prisma.BannerCreateInput });
return NextResponse.json({ banner }, { status: 201 });
}

View File

@@ -5,7 +5,7 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
const { id } = await context.params;
const body = await req.json().catch(() => ({}));
const data: any = {};
for (const k of ["name", "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 ("requiredTags" in body) {
@@ -18,4 +18,11 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
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 });
}

View File

@@ -1,8 +1,11 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import type { Prisma } from "@prisma/client";
import { z } from "zod";
export async function GET() {
const boards = await prisma.board.findMany({
where: { NOT: { status: 'archived' } },
orderBy: { sortOrder: "asc" },
select: {
id: true,
@@ -14,14 +17,52 @@ export async function GET() {
writeLevel: true,
allowAnonymousPost: true,
allowSecretComment: true,
requiresApproval: true,
type: true,
status: true,
categoryId: true,
mainPageViewTypeId: true,
listViewTypeId: true,
category: { select: { id: true, name: true, slug: true } },
},
});
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 });
}

View File

@@ -1,12 +1,8 @@
import { NextResponse } from "next/server";
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 }> }) {
const { id } = await context.params;
const userId = getUserIdFromRequest(req);
await requirePermission({ userId, resource: "ADMIN", action: "MODERATE" });
const body = await req.json().catch(() => ({}));
const data: any = {};
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 }> }) {
const { id } = await context.params;
const userId = getUserIdFromRequest(req);
await requirePermission({ userId, resource: "ADMIN", action: "MODERATE" });
await prisma.boardCategory.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

View File

@@ -1,8 +1,7 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
import { getUserIdFromRequest } from "@/lib/auth";
import { requirePermission } from "@/lib/rbac";
import type { Prisma } from "@prisma/client";
export async function GET() {
const categories = await prisma.boardCategory.findMany({
@@ -19,12 +18,10 @@ const createSchema = z.object({
});
export async function POST(req: Request) {
const userId = getUserIdFromRequest(req);
await requirePermission({ userId, resource: "ADMIN", action: "MODERATE" });
const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const category = await prisma.boardCategory.create({ data: parsed.data });
const category = await prisma.boardCategory.create({ data: parsed.data as Prisma.BoardCategoryCreateInput });
return NextResponse.json({ category }, { status: 201 });
}

View File

@@ -0,0 +1,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 });
}

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
const body = await req.json().catch(() => ({}));
const data: any = {};
for (const k of ["region", "name", "address", "imageUrl", "active", "sortOrder"]) {
if (k in body) data[k] = body[k];
}
const item = await prisma.partnerRequest.update({ where: { id }, data });
return NextResponse.json({ item });
}
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
await prisma.partnerRequest.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

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