Merge branch 'mainwork'
@@ -1,17 +1,18 @@
|
|||||||
배너 디테일
|
|
||||||
카드 디테일
|
|
||||||
메인 디테일 프리뷰, 글, 스페셜_랭크
|
|
||||||
|
|
||||||
기본 리스트 , 글이 없습니다.
|
|
||||||
글쓰기
|
|
||||||
글뷰, 댓글 +리스트
|
|
||||||
|
|
||||||
|
메인 게시판 일반
|
||||||
|
메인 게시판 프리뷰
|
||||||
|
메인 게시판 스페셜랭크
|
||||||
|
|
||||||
|
기본 리스트
|
||||||
스페셜_랭크
|
스페셜_랭크
|
||||||
스페셜_출석
|
스페셜_출석
|
||||||
스페셜_제휴업체
|
스페셜_제휴업체
|
||||||
스페셜_제휴업체지도
|
스페셜_제휴업체지도
|
||||||
|
|
||||||
|
게시글 뷰 + 댓글
|
||||||
|
|
||||||
로그인관련
|
로그인관련
|
||||||
|
회원가입 페이지
|
||||||
회원쪽지
|
회원쪽지
|
||||||
링크로들어오면 보이고 거기서 페이지이동하면안보이게
|
링크로들어오면 보이고 거기서 페이지 이동하면 안보이게
|
||||||
10
package.json
@@ -8,12 +8,10 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "biome check",
|
"lint": "biome check",
|
||||||
"format": "biome format --write",
|
"format": "biome format --write",
|
||||||
"prisma:generate": "prisma generate",
|
"migrate": "prisma migrate dev",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"studio": "prisma studio",
|
||||||
"prisma:studio": "prisma studio",
|
"seed": "node prisma/seed.js",
|
||||||
"prisma:db:push": "prisma db push",
|
"dbforce": "prisma migrate reset --force"
|
||||||
"prisma:seed": "node prisma/seed.js",
|
|
||||||
"prisma:erd": "prisma generate"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.17.0",
|
"@prisma/client": "^6.17.0",
|
||||||
|
|||||||
18
prisma/migrations/20251101152100_/migration.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "partner_shops" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"region" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"address" TEXT NOT NULL,
|
||||||
|
"imageUrl" TEXT NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "partner_shops_active_sortOrder_idx" ON "partner_shops"("active", "sortOrder");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "partner_shops_name_region_key" ON "partner_shops"("name", "region");
|
||||||
2
prisma/migrations/20251101161632_/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "partners" ADD COLUMN "imageUrl" TEXT;
|
||||||
23
prisma/migrations/20251101162445_/migration.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_partners" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"latitude" REAL NOT NULL,
|
||||||
|
"longitude" REAL NOT NULL,
|
||||||
|
"address" TEXT,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO "new_partners" ("address", "category", "createdAt", "id", "imageUrl", "latitude", "longitude", "name", "updatedAt") SELECT "address", "category", "createdAt", "id", "imageUrl", "latitude", "longitude", "name", "updatedAt" FROM "partners";
|
||||||
|
DROP TABLE "partners";
|
||||||
|
ALTER TABLE "new_partners" RENAME TO "partners";
|
||||||
|
CREATE UNIQUE INDEX "partners_name_key" ON "partners"("name");
|
||||||
|
CREATE INDEX "partners_category_idx" ON "partners"("category");
|
||||||
|
CREATE INDEX "partners_sortOrder_idx" ON "partners"("sortOrder");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Add points/level/grade columns to users
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
|
||||||
|
-- points
|
||||||
|
ALTER TABLE "users" ADD COLUMN "points" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- level
|
||||||
|
ALTER TABLE "users" ADD COLUMN "level" INTEGER NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
-- grade (0~10 권장, DB 레벨에서는 정수 제약만, 범위는 앱에서 검증)
|
||||||
|
ALTER TABLE "users" ADD COLUMN "grade" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
|
||||||
|
|
||||||
@@ -24,10 +24,6 @@ enum BoardStatus {
|
|||||||
archived
|
archived
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BoardType {
|
|
||||||
general
|
|
||||||
special
|
|
||||||
}
|
|
||||||
|
|
||||||
enum AccessLevel {
|
enum AccessLevel {
|
||||||
public // 비회원도 접근
|
public // 비회원도 접근
|
||||||
@@ -128,8 +124,6 @@ model Board {
|
|||||||
description String?
|
description String?
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
status BoardStatus @default(active)
|
status BoardStatus @default(active)
|
||||||
type BoardType @default(general) // 일반/특수
|
|
||||||
requiresApproval Boolean @default(false) // 게시물 승인 필요 여부
|
|
||||||
allowAnonymousPost Boolean @default(false) // 익명 글 허용
|
allowAnonymousPost Boolean @default(false) // 익명 글 허용
|
||||||
allowSecretComment Boolean @default(false) // 비밀댓글 허용
|
allowSecretComment Boolean @default(false) // 비밀댓글 허용
|
||||||
isAdultOnly Boolean @default(false) // 성인 인증 필요 여부
|
isAdultOnly Boolean @default(false) // 성인 인증 필요 여부
|
||||||
@@ -156,7 +150,6 @@ model Board {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([status, sortOrder])
|
@@index([status, sortOrder])
|
||||||
@@index([type, requiresApproval])
|
|
||||||
@@index([categoryId])
|
@@index([categoryId])
|
||||||
@@index([mainPageViewTypeId])
|
@@index([mainPageViewTypeId])
|
||||||
@@index([listViewTypeId])
|
@@index([listViewTypeId])
|
||||||
@@ -239,6 +232,10 @@ model User {
|
|||||||
birth DateTime
|
birth DateTime
|
||||||
phone String @unique
|
phone String @unique
|
||||||
rank Int @default(0)
|
rank Int @default(0)
|
||||||
|
// 누적 포인트, 레벨, 등급(0~10)
|
||||||
|
points Int @default(0)
|
||||||
|
level Int @default(1)
|
||||||
|
grade Int @default(0)
|
||||||
|
|
||||||
status UserStatus @default(active)
|
status UserStatus @default(active)
|
||||||
authLevel AuthLevel @default(USER)
|
authLevel AuthLevel @default(USER)
|
||||||
@@ -675,10 +672,13 @@ model Partner {
|
|||||||
latitude Float
|
latitude Float
|
||||||
longitude Float
|
longitude Float
|
||||||
address String?
|
address String?
|
||||||
|
imageUrl String?
|
||||||
|
sortOrder Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([category])
|
@@index([category])
|
||||||
|
@@index([sortOrder])
|
||||||
@@map("partners")
|
@@map("partners")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,3 +742,20 @@ model Setting {
|
|||||||
|
|
||||||
@@map("settings")
|
@@map("settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 메인 노출용 제휴 샵 가로 스크롤 데이터
|
||||||
|
model PartnerShop {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
region String
|
||||||
|
name String
|
||||||
|
address String
|
||||||
|
imageUrl String
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([name, region])
|
||||||
|
@@index([active, sortOrder])
|
||||||
|
@@map("partner_shops")
|
||||||
|
}
|
||||||
|
|||||||
253
prisma/seed.js
@@ -2,16 +2,87 @@ const { PrismaClient } = require("@prisma/client");
|
|||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// 닉네임을 결정론적으로 생성해 재실행 시 중복 생성 방지
|
||||||
|
const nickname = `user${String(i + 1).padStart(3, "0")}`;
|
||||||
|
const existing = await prisma.user.findUnique({ where: { nickname } });
|
||||||
|
let user = existing;
|
||||||
|
if (!existing) {
|
||||||
|
const name = generateRandomKoreanName();
|
||||||
|
const birth = randomDate(1975, 2005);
|
||||||
|
const phone = await findAvailablePhone(i + 2); // admin이 0001 사용하므로 겹치지 않도록 오프셋
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
nickname,
|
||||||
|
name,
|
||||||
|
birth,
|
||||||
|
phone,
|
||||||
|
agreementTermsAt: new Date(),
|
||||||
|
authLevel: "USER",
|
||||||
|
isAdultVerified: Math.random() < 0.6,
|
||||||
|
lastLoginAt: Math.random() < 0.8 ? new Date() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (roleUser && user) {
|
||||||
|
await prisma.userRole.upsert({
|
||||||
|
where: { userId_roleId: { userId: user.userId, roleId: roleUser.roleId } },
|
||||||
|
update: {},
|
||||||
|
create: { userId: user.userId, roleId: roleUser.roleId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function upsertCategories() {
|
async function upsertCategories() {
|
||||||
// 카테고리 트리 (projectmemo 기준 상위 그룹)
|
// 카테고리 트리 (projectmemo 기준 상위 그룹)
|
||||||
const categories = [
|
const categories = [
|
||||||
{ name: "암실소문", slug: "main", sortOrder: 1, status: "active" },
|
{ name: "메인", slug: "main", sortOrder: 1, status: "active" },
|
||||||
{ name: "명예의 전당", slug: "hall-of-fame", sortOrder: 2, status: "active" },
|
{ name: "명예의 전당", slug: "hall-of-fame", sortOrder: 2, status: "active" },
|
||||||
{ name: "주변 제휴업체", slug: "nearby-partners", sortOrder: 3, status: "active" },
|
{ name: "소통방", slug: "community", sortOrder: 3, status: "active" },
|
||||||
{ name: "제휴업소 정보", slug: "partner-info", sortOrder: 4, status: "active" },
|
|
||||||
{ name: "방문후기", slug: "reviews", sortOrder: 5, status: "active" },
|
|
||||||
{ name: "소통방", slug: "community", sortOrder: 6, status: "active" },
|
|
||||||
{ name: "광고/제휴", slug: "ads-affiliates", sortOrder: 7, status: "active" },
|
|
||||||
];
|
];
|
||||||
const map = {};
|
const map = {};
|
||||||
for (const c of categories) {
|
for (const c of categories) {
|
||||||
@@ -111,31 +182,28 @@ async function upsertAdmin() {
|
|||||||
async function upsertBoards(admin, categoryMap) {
|
async function upsertBoards(admin, categoryMap) {
|
||||||
const boards = [
|
const boards = [
|
||||||
// 일반
|
// 일반
|
||||||
{ name: "공지사항", slug: "notice", description: "공지", type: "general", sortOrder: 1, writeLevel: "moderator" },
|
{ name: "공지사항", slug: "notice", description: "공지", sortOrder: 1, writeLevel: "moderator" },
|
||||||
{ name: "가입인사", slug: "greetings", description: "가입인사", type: "general", sortOrder: 2 },
|
{ name: "가입인사", slug: "greetings", description: "가입인사", sortOrder: 2 },
|
||||||
{ name: "버그건의", slug: "bug-report", description: "버그/건의", type: "general", sortOrder: 3 },
|
{ name: "버그건의", slug: "bug-report", description: "버그/건의", sortOrder: 3 },
|
||||||
{ name: "이벤트", slug: "event", description: "이벤트", type: "general", sortOrder: 4, requiredTags: { required: ["이벤트"] } },
|
{ name: "이벤트", slug: "event", description: "이벤트", sortOrder: 4, requiredTags: { required: ["이벤트"] } },
|
||||||
{ name: "자유게시판", slug: "free", description: "자유", type: "general", sortOrder: 5 },
|
{ name: "자유게시판", slug: "free", description: "자유", sortOrder: 5 },
|
||||||
{ name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", type: "general", sortOrder: 6 },
|
{ name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", sortOrder: 6 },
|
||||||
{ name: "마사지꿀팁", slug: "tips", description: "팁", type: "general", sortOrder: 7 },
|
{ name: "익명게시판", slug: "anonymous", description: "익명", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true },
|
||||||
{ name: "익명게시판", slug: "anonymous", description: "익명", type: "general", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true },
|
{ name: "청와대", slug: "blue-house", description: "레벨 제한", sortOrder: 10, readLevel: "member" },
|
||||||
{ name: "관리사찾아요", slug: "find-therapist", description: "구인/구직", type: "general", sortOrder: 9 },
|
|
||||||
{ name: "청와대", slug: "blue-house", description: "레벨 제한", type: "general", sortOrder: 10, readLevel: "member" },
|
|
||||||
{ name: "방문후기", slug: "reviews", description: "운영자 승인 후 공개", type: "general", sortOrder: 11, requiresApproval: true, requiredTags: { anyOf: ["업체명", "지역"] } },
|
|
||||||
// 특수
|
// 특수
|
||||||
{ name: "출석부", slug: "attendance", description: "데일리 체크인", type: "special", sortOrder: 12 },
|
{ name: "출석부", slug: "attendance", description: "데일리 체크인", sortOrder: 12 },
|
||||||
{ name: "주변 제휴업체", slug: "nearby-partners", description: "위치 기반", type: "special", sortOrder: 13 },
|
{ name: "회원랭킹", slug: "ranking", description: "랭킹", sortOrder: 14 },
|
||||||
{ name: "회원랭킹", slug: "ranking", description: "랭킹", type: "special", sortOrder: 14 },
|
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", sortOrder: 15 },
|
||||||
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", type: "special", sortOrder: 15 },
|
{ name: "월간집계", slug: "monthly-stats", description: "월간 통계", sortOrder: 16 },
|
||||||
{ name: "월간집계", slug: "monthly-stats", description: "월간 통계", type: "special", sortOrder: 16 },
|
// 광고/제휴 계열 게시판은 제거(메인/명예의전당/소통방 외)
|
||||||
// 제휴업소 일반
|
|
||||||
{ name: "제휴업소", slug: "partners-photos", description: "사진 전용 게시판", type: "general", sortOrder: 17, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
|
|
||||||
// 광고/제휴
|
|
||||||
{ name: "제휴문의", slug: "partner-contact", description: "제휴문의", type: "general", sortOrder: 18, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
|
|
||||||
{ name: "제휴업소 요청", slug: "partner-req", description: "제휴업소 요청", type: "general", sortOrder: 19, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const created = [];
|
const created = [];
|
||||||
|
// 특수 랭킹/텍스트 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨)
|
||||||
|
const mainSpecial = await prisma.boardViewType.findUnique({ where: { key: "main_special_rank" } });
|
||||||
|
const listSpecial = await prisma.boardViewType.findUnique({ where: { key: "list_special_rank" } });
|
||||||
|
const mainText = await prisma.boardViewType.findUnique({ where: { key: "main_text" } });
|
||||||
|
|
||||||
for (const b of boards) {
|
for (const b of boards) {
|
||||||
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
|
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
|
||||||
const mapBySlug = {
|
const mapBySlug = {
|
||||||
@@ -149,22 +217,12 @@ async function upsertBoards(admin, categoryMap) {
|
|||||||
ranking: "hall-of-fame",
|
ranking: "hall-of-fame",
|
||||||
"free-coupons": "hall-of-fame",
|
"free-coupons": "hall-of-fame",
|
||||||
"monthly-stats": "hall-of-fame",
|
"monthly-stats": "hall-of-fame",
|
||||||
// 주변 제휴업체
|
|
||||||
"nearby-partners": "nearby-partners",
|
|
||||||
// 제휴업소 정보
|
|
||||||
"partners-photos": "partner-info",
|
|
||||||
// 방문후기
|
|
||||||
reviews: "reviews",
|
|
||||||
// 소통방(기본값 community로 처리)
|
// 소통방(기본값 community로 처리)
|
||||||
free: "community",
|
free: "community",
|
||||||
qna: "community",
|
qna: "community",
|
||||||
tips: "community",
|
tips: "community",
|
||||||
anonymous: "community",
|
anonymous: "community",
|
||||||
"find-therapist": "community",
|
|
||||||
"blue-house": "community",
|
|
||||||
// 광고/제휴
|
// 광고/제휴
|
||||||
"partner-contact": "ads-affiliates",
|
|
||||||
"partner-req": "ads-affiliates",
|
|
||||||
};
|
};
|
||||||
const categorySlug = mapBySlug[b.slug] || "community";
|
const categorySlug = mapBySlug[b.slug] || "community";
|
||||||
const category = categoryMap[categorySlug];
|
const category = categoryMap[categorySlug];
|
||||||
@@ -173,22 +231,28 @@ async function upsertBoards(admin, categoryMap) {
|
|||||||
update: {
|
update: {
|
||||||
description: b.description,
|
description: b.description,
|
||||||
sortOrder: b.sortOrder,
|
sortOrder: b.sortOrder,
|
||||||
type: b.type,
|
|
||||||
requiresApproval: !!b.requiresApproval,
|
|
||||||
allowAnonymousPost: !!b.allowAnonymousPost,
|
allowAnonymousPost: !!b.allowAnonymousPost,
|
||||||
readLevel: b.readLevel || undefined,
|
readLevel: b.readLevel || undefined,
|
||||||
categoryId: category ? category.id : undefined,
|
categoryId: category ? category.id : undefined,
|
||||||
|
// 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지
|
||||||
|
...(b.slug === "ranking"
|
||||||
|
? (mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {})
|
||||||
|
: (mainText ? { mainPageViewTypeId: mainText.id } : {})),
|
||||||
|
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
name: b.name,
|
name: b.name,
|
||||||
slug: b.slug,
|
slug: b.slug,
|
||||||
description: b.description,
|
description: b.description,
|
||||||
sortOrder: b.sortOrder,
|
sortOrder: b.sortOrder,
|
||||||
type: b.type,
|
|
||||||
requiresApproval: !!b.requiresApproval,
|
|
||||||
allowAnonymousPost: !!b.allowAnonymousPost,
|
allowAnonymousPost: !!b.allowAnonymousPost,
|
||||||
readLevel: b.readLevel || undefined,
|
readLevel: b.readLevel || undefined,
|
||||||
categoryId: category ? category.id : undefined,
|
categoryId: category ? category.id : undefined,
|
||||||
|
// 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지
|
||||||
|
...(b.slug === "ranking"
|
||||||
|
? (mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {})
|
||||||
|
: (mainText ? { mainPageViewTypeId: mainText.id } : {})),
|
||||||
|
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
created.push(board);
|
created.push(board);
|
||||||
@@ -229,6 +293,35 @@ async function upsertViewTypes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeNonPrimaryBoards() {
|
||||||
|
// 메인/명예의전당/소통방에 해당하지 않는 게시판(광고/제휴 계열) 정리
|
||||||
|
await prisma.board.deleteMany({
|
||||||
|
where: { slug: { in: ["partners-photos", "partner-contact", "partner-req"] } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPostsForAllBoards(boards, countPerBoard = 100, admin) {
|
||||||
|
const users = await prisma.user.findMany({ select: { userId: true } });
|
||||||
|
const userIds = users.map((u) => u.userId);
|
||||||
|
for (const board of boards) {
|
||||||
|
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)];
|
||||||
|
data.push({
|
||||||
|
boardId: board.id,
|
||||||
|
authorId,
|
||||||
|
title: `${board.name} 샘플 글 ${i + 1}`,
|
||||||
|
content: `이 게시판(${board.slug})의 자동 시드 게시물 #${i + 1} 입니다.\n\n테스트용 내용입니다.`,
|
||||||
|
status: "published",
|
||||||
|
isAnonymous: board.allowAnonymousPost ? Math.random() < 0.3 : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.post.createMany({ data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function seedPolicies() {
|
async function seedPolicies() {
|
||||||
// 금칙어 예시
|
// 금칙어 예시
|
||||||
const banned = [
|
const banned = [
|
||||||
@@ -259,37 +352,75 @@ 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.partnerShop.upsert({
|
||||||
|
where: { name_region: { name: it.name, region: it.region } },
|
||||||
|
update: { address: it.address, imageUrl: it.imageUrl, sortOrder: it.sortOrder, active: true },
|
||||||
|
create: it,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 표시 토글 기본값 보장
|
||||||
|
const SETTINGS_KEY = "mainpage_settings";
|
||||||
|
const setting = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
|
||||||
|
const current = setting ? JSON.parse(setting.value) : {};
|
||||||
|
const next = { showPartnerShops: true, ...current };
|
||||||
|
await prisma.setting.upsert({
|
||||||
|
where: { key: SETTINGS_KEY },
|
||||||
|
update: { value: JSON.stringify(next) },
|
||||||
|
create: { key: SETTINGS_KEY, value: JSON.stringify(next) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedBanners() {
|
||||||
|
// 기존 배너 정리 후 5개만 채움
|
||||||
|
await prisma.banner.deleteMany({});
|
||||||
|
const items = Array.from({ length: 5 }).map((_, i) => ({
|
||||||
|
title: `메인 배너 ${i + 1}`,
|
||||||
|
imageUrl: "/sample.jpg",
|
||||||
|
linkUrl: "/",
|
||||||
|
sortOrder: i + 1,
|
||||||
|
active: true,
|
||||||
|
}));
|
||||||
|
await prisma.banner.createMany({ data: items });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedMainpageVisibleBoards(boards) {
|
||||||
|
const SETTINGS_KEY = "mainpage_settings";
|
||||||
|
const setting = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
|
||||||
|
const current = setting ? JSON.parse(setting.value) : {};
|
||||||
|
const wantSlugs = new Set(["notice", "free", "ranking"]);
|
||||||
|
const visibleBoardIds = boards.filter((b) => wantSlugs.has(b.slug)).map((b) => b.id);
|
||||||
|
const next = { ...current, visibleBoardIds };
|
||||||
|
await prisma.setting.upsert({
|
||||||
|
where: { key: SETTINGS_KEY },
|
||||||
|
update: { value: JSON.stringify(next) },
|
||||||
|
create: { key: SETTINGS_KEY, value: JSON.stringify(next) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await upsertRoles();
|
await upsertRoles();
|
||||||
const admin = await upsertAdmin();
|
const admin = await upsertAdmin();
|
||||||
const categoryMap = await upsertCategories();
|
const categoryMap = await upsertCategories();
|
||||||
await upsertViewTypes();
|
await upsertViewTypes();
|
||||||
|
await createRandomUsers(100);
|
||||||
|
await removeNonPrimaryBoards();
|
||||||
const boards = await upsertBoards(admin, categoryMap);
|
const boards = await upsertBoards(admin, categoryMap);
|
||||||
|
await seedMainpageVisibleBoards(boards);
|
||||||
// 샘플 글 하나
|
await createPostsForAllBoards(boards, 100, admin);
|
||||||
const free = boards.find((b) => b.slug === "free") || boards[0];
|
await seedPartnerShops();
|
||||||
const post = await prisma.post.create({
|
await seedBanners();
|
||||||
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 seedPolicies();
|
await seedPolicies();
|
||||||
|
|
||||||
// 제휴업체 예시 데이터
|
// 제휴업체 예시 데이터
|
||||||
const partners = [
|
const partners = [
|
||||||
{ name: "힐링마사지", category: "spa", latitude: 37.5665, longitude: 126.9780, address: "서울 중구" },
|
{ name: "test", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
|
||||||
{ name: "웰빙테라피", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
|
|
||||||
{ name: "굿짐", category: "gym", latitude: 37.5600, longitude: 126.9820, address: "서울 중구" },
|
{ name: "굿짐", category: "gym", latitude: 37.5600, longitude: 126.9820, address: "서울 중구" },
|
||||||
];
|
];
|
||||||
for (const p of partners) {
|
for (const p of partners) {
|
||||||
|
|||||||
BIN
public/uploads/1762011428045-b2uxup0646v.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762011528689-r5qbw3daoq.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762012666810-zhoeib9y8we.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762013315968-jent9fluatl.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762013865590-izqoqn8qgbm.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762014531262-2vmcxdk945u.jpg
Normal file
|
After Width: | Height: | Size: 411 KiB |
BIN
public/uploads/1762014639578-9e1067twpw.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/uploads/1762014695925-jmes4cxd0vd.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762014716631-fgq5a179wwr.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762014821297-9qbwphmxm05.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762015671690-p67kkblxdml.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762015830912-1wsv0cfchd8.jpg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
public/uploads/1762016086149-vcxoon8tg8.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762016548518-0d2zhs3f44bq.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762017507500-08hp85ex35v.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762017553592-w9qnbapfb2.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762017624031-rni0unzdl6c.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/uploads/1762025382776-83vifeqk7rk.webp
Normal file
|
After Width: | Height: | Size: 279 KiB |
@@ -7,6 +7,7 @@ const navItems = [
|
|||||||
{ href: "/admin/mainpage-settings", label: "메인페이지 설정" },
|
{ href: "/admin/mainpage-settings", label: "메인페이지 설정" },
|
||||||
{ href: "/admin/boards", label: "게시판" },
|
{ href: "/admin/boards", label: "게시판" },
|
||||||
{ href: "/admin/banners", label: "배너" },
|
{ href: "/admin/banners", label: "배너" },
|
||||||
|
{ href: "/admin/partners", label: "제휴업체" },
|
||||||
{ href: "/admin/users", label: "사용자" },
|
{ href: "/admin/users", label: "사용자" },
|
||||||
{ href: "/admin/logs", label: "로그" },
|
{ href: "/admin/logs", label: "로그" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { Modal } from "@/app/components/ui/Modal";
|
||||||
|
|
||||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
@@ -8,21 +9,112 @@ export default function AdminBannersPage() {
|
|||||||
const { data, mutate } = useSWR<{ banners: any[] }>("/api/admin/banners", fetcher);
|
const { data, mutate } = useSWR<{ banners: any[] }>("/api/admin/banners", fetcher);
|
||||||
const banners = data?.banners ?? [];
|
const banners = data?.banners ?? [];
|
||||||
const [form, setForm] = useState({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 });
|
const [form, setForm] = useState({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 });
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
async function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const inputEl = e.currentTarget; // 이벤트 풀링 대비 사본 보관
|
||||||
|
const file = inputEl.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
setUploading(true);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const r = await fetch("/api/uploads", { method: "POST", body: fd });
|
||||||
|
const json = await r.json();
|
||||||
|
if (!r.ok) throw new Error(json?.error || "upload_failed");
|
||||||
|
setForm((f) => ({ ...f, imageUrl: json.url }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert("이미지 업로드 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (inputEl) inputEl.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
async function create() {
|
async function create() {
|
||||||
const r = await fetch("/api/admin/banners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(form) });
|
const r = await fetch("/api/admin/banners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(form) });
|
||||||
if (r.ok) { setForm({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 }); mutate(); }
|
if (r.ok) { setForm({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 }); mutate(); setShowCreateModal(false); }
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>배너 관리</h1>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
|
||||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
<h1>배너 관리</h1>
|
||||||
<input placeholder="제목" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
<button
|
||||||
<input placeholder="이미지 URL" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
|
type="button"
|
||||||
<input placeholder="링크 URL(선택)" value={form.linkUrl} onChange={(e) => setForm({ ...form, linkUrl: e.target.value })} />
|
onClick={() => setShowCreateModal(true)}
|
||||||
<label><input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} /> 활성</label>
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
||||||
<input type="number" placeholder="정렬" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} style={{ width: 80 }} />
|
>
|
||||||
<button onClick={create}>추가</button>
|
추가
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<Modal open={showCreateModal} onClose={() => setShowCreateModal(false)}>
|
||||||
|
<div className="w-[720px] max-w-[90vw]">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-lg font-bold mb-4">배너 추가</h2>
|
||||||
|
{(() => {
|
||||||
|
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
|
||||||
|
return (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
|
||||||
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>제목</label>
|
||||||
|
<input style={inputStyle} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>링크 URL(선택)</label>
|
||||||
|
<input style={inputStyle} value={form.linkUrl} onChange={(e) => setForm({ ...form, linkUrl: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 6" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>이미지 URL</label>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
<input style={inputStyle} value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
업로드
|
||||||
|
</button>
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={onSelectFile} style={{ display: "none" }} />
|
||||||
|
{uploading && <span style={{ fontSize: 12, opacity: .7 }}>업로드 중...</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>활성</label>
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm"><input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} /> 활성화</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ gridColumn: "span 3" }}>
|
||||||
|
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>정렬</label>
|
||||||
|
<input type="number" style={inputStyle} value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
{form.imageUrl && (
|
||||||
|
<div style={{ gridColumn: "span 6" }}>
|
||||||
|
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}>미리보기</div>
|
||||||
|
<img src={form.imageUrl} alt="미리보기" style={{ width: 320, height: 160, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={create}
|
||||||
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
{banners.map((b) => (
|
{banners.map((b) => (
|
||||||
<li key={b.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "flex", gap: 12, alignItems: "center" }}>
|
<li key={b.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "flex", gap: 12, alignItems: "center" }}>
|
||||||
@@ -31,8 +123,8 @@ export default function AdminBannersPage() {
|
|||||||
<div><strong>{b.title}</strong> {b.linkUrl && <a style={{ marginLeft: 8 }} href={b.linkUrl}>링크</a>}</div>
|
<div><strong>{b.title}</strong> {b.linkUrl && <a style={{ marginLeft: 8 }} href={b.linkUrl}>링크</a>}</div>
|
||||||
<div style={{ fontSize: 12, opacity: 0.7 }}>정렬 {b.sortOrder} · {b.active ? "활성" : "비활성"}</div>
|
<div style={{ fontSize: 12, opacity: 0.7 }}>정렬 {b.sortOrder} · {b.active ? "활성" : "비활성"}</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: !b.active }) }); mutate(); }}>{b.active ? "비활성" : "활성"}</button>
|
<button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: !b.active }) }); mutate(); }} className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition">{b.active ? "비활성" : "활성"}</button>
|
||||||
<button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "DELETE" }); mutate(); }}>삭제</button>
|
<button onClick={async () => { await fetch(`/api/admin/banners/${b.id}`, { method: "DELETE" }); mutate(); }} className="h-8 px-3 rounded-md border border-red-200 text-red-600 text-sm cursor-pointer hover:bg-red-100 hover:border-red-300 hover:text-red-700 active:translate-y-px transition">삭제</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -368,8 +368,6 @@ export default function AdminBoardsPage() {
|
|||||||
<th className="px-3 py-2">쓰기</th>
|
<th className="px-3 py-2">쓰기</th>
|
||||||
<th className="px-3 py-2">익명</th>
|
<th className="px-3 py-2">익명</th>
|
||||||
<th className="px-3 py-2">비밀댓</th>
|
<th className="px-3 py-2">비밀댓</th>
|
||||||
<th className="px-3 py-2">승인</th>
|
|
||||||
<th className="px-3 py-2">유형</th>
|
|
||||||
<th className="px-3 py-2">성인</th>
|
<th className="px-3 py-2">성인</th>
|
||||||
<th className="px-3 py-2">대분류 이동</th>
|
<th className="px-3 py-2">대분류 이동</th>
|
||||||
<th className="px-3 py-2">활성</th>
|
<th className="px-3 py-2">활성</th>
|
||||||
@@ -502,13 +500,6 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => { const v = { ...edit, allowAnonymousPost: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => { const v = { ...edit, allowAnonymousPost: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||||
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowSecretComment} onChange={(e) => { const v = { ...edit, allowSecretComment: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowSecretComment} onChange={(e) => { const v = { ...edit, allowSecretComment: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||||
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.requiresApproval} onChange={(e) => { const v = { ...edit, requiresApproval: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
|
||||||
<td className="px-3 py-2 text-center">
|
|
||||||
<select className="h-9 rounded-md border border-neutral-300 px-2 text-sm" value={edit.type} onChange={(e) => { const v = { ...edit, type: e.target.value }; setEdit(v); onDirty(b.id, v); }}>
|
|
||||||
<option value="general">general</option>
|
|
||||||
<option value="special">special</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-center"><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => { const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
<td className="px-3 py-2 text-center"><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => { const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
|
||||||
{allowMove && categories && onMove ? (
|
{allowMove && categories && onMove ? (
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
|
|||||||
45
src/app/admin/partner-shops/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
export default function AdminPartnerShopsPage() {
|
||||||
|
const { data, mutate } = useSWR<{ items: any[] }>("/api/admin/partner-shops", fetcher);
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
const [form, setForm] = useState({ region: "", name: "", address: "", imageUrl: "", active: true, sortOrder: 0 });
|
||||||
|
async function create() {
|
||||||
|
const r = await fetch("/api/admin/partner-shops", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(form) });
|
||||||
|
if (r.ok) { setForm({ region: "", name: "", address: "", imageUrl: "", active: true, sortOrder: 0 }); mutate(); }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>제휴 샵 관리</h1>
|
||||||
|
<div style={{ display: "flex", gap: 8, marginBottom: 12, flexWrap: "wrap" }}>
|
||||||
|
<input placeholder="지역" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} />
|
||||||
|
<input placeholder="이름" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
|
<input placeholder="주소" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} style={{ width: 320 }} />
|
||||||
|
<input placeholder="이미지 URL" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} style={{ width: 280 }} />
|
||||||
|
<label><input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} /> 활성</label>
|
||||||
|
<input type="number" placeholder="정렬" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} style={{ width: 80 }} />
|
||||||
|
<button onClick={create}>추가</button>
|
||||||
|
</div>
|
||||||
|
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{items.map((it) => (
|
||||||
|
<li key={it.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "flex", gap: 12, alignItems: "center" }}>
|
||||||
|
<img src={it.imageUrl} alt={it.name} style={{ width: 80, height: 48, objectFit: "cover", borderRadius: 6 }} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div><strong>{it.name}</strong> <span style={{ marginLeft: 8, fontSize: 12, opacity: .7 }}>{it.region}</span></div>
|
||||||
|
<div style={{ fontSize: 12, opacity: 0.7 }}>{it.address}</div>
|
||||||
|
<div style={{ fontSize: 12, opacity: 0.7 }}>정렬 {it.sortOrder} · {it.active ? "활성" : "비활성"}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={async () => { await fetch(`/api/admin/partner-shops/${it.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: !it.active }) }); mutate(); }}>{it.active ? "비활성" : "활성"}</button>
|
||||||
|
<button onClick={async () => { await fetch(`/api/admin/partner-shops/${it.id}`, { method: "DELETE" }); mutate(); }}>삭제</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
295
src/app/admin/partners/page.tsx
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
"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 partners = data?.partners ?? [];
|
||||||
|
const [form, setForm] = useState({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
|
||||||
|
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.category || !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 } as any;
|
||||||
|
const r = await fetch("/api/admin/partners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
||||||
|
if (r.ok) {
|
||||||
|
setForm({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
|
||||||
|
mutate();
|
||||||
|
setShowCreateModal(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<input style={inputStyle} value={form.category} onChange={(e) => setForm({ ...form, category: 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.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>
|
||||||
|
<input style={inputStyle} value={editDraft?.category ?? ""} onChange={(e) => setEditDraft({ ...editDraft, category: 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?.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; await fetch(`/api/admin/partners/${editingId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...editDraft }) }); 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> <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>{p.category}</span></div>
|
||||||
|
<div style={{ fontSize: 12, opacity: .7 }}>{p.address || "(주소 없음)"}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, opacity: .7 }}>위도 {p.latitude}</div>
|
||||||
|
<div style={{ fontSize: 12, opacity: .7 }}>경도 {p.longitude}</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingId(p.id); setEditDraft({ name: p.name, category: p.category, latitude: String(p.latitude), longitude: String(p.longitude), address: p.address ?? "", imageUrl: p.imageUrl ?? "", sortOrder: p.sortOrder ?? 0 }); setShowEditModal(true); }}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,36 +1,80 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const { data, mutate } = useSWR<{ users: any[] }>(`/api/admin/users?q=${encodeURIComponent(q)}`, fetcher);
|
const [queryDraft, setQueryDraft] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const key = useMemo(() => (
|
||||||
|
`/api/admin/users?q=${encodeURIComponent(q)}&page=${page}&pageSize=${pageSize}`
|
||||||
|
),[q, page, pageSize]);
|
||||||
|
const { data, mutate } = useSWR<{ total: number; page: number; pageSize: number; users: any[] }>(key, fetcher);
|
||||||
const users = data?.users ?? [];
|
const users = data?.users ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const handleSearch = () => {
|
||||||
|
const term = queryDraft.trim();
|
||||||
|
if (!term) { setQ(""); setPage(1); return; }
|
||||||
|
setQ(term);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>사용자 관리</h1>
|
<h1>사용자 관리</h1>
|
||||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
<div className="mb-3 rounded-xl border border-neutral-300 bg-white p-3 flex items-center gap-2">
|
||||||
<input placeholder="검색(nickname/phone/name)" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input
|
||||||
|
className="h-9 w-full max-w-[360px] px-3 rounded-md border border-neutral-300 bg-white text-sm"
|
||||||
|
placeholder="검색 (닉네임/전화/이름)"
|
||||||
|
value={queryDraft}
|
||||||
|
onChange={(e) => setQueryDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100 disabled:opacity-50"
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={false}
|
||||||
|
>
|
||||||
|
검색
|
||||||
|
</button>
|
||||||
|
<span className="ml-auto text-xs text-neutral-600">총 {total.toLocaleString()}명</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-neutral-200 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-[#f6f4f4] border-b border-[#e6e6e6]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">닉네임</th>
|
||||||
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">이름</th>
|
||||||
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">전화</th>
|
||||||
|
<th className="px-4 py-2 text-right text-[12px] text-[#8c8c8c]">포인트</th>
|
||||||
|
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]">레벨</th>
|
||||||
|
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]">등급</th>
|
||||||
|
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]">상태</th>
|
||||||
|
<th className="px-4 py-2 text-left text-[12px] text-[#8c8c8c]">권한</th>
|
||||||
|
<th className="px-4 py-2 text-center text-[12px] text-[#8c8c8c]">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#ececec] bg-white">
|
||||||
|
{users.map((u) => (
|
||||||
|
<Row key={u.userId} u={u} onChanged={mutate} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 12 }}>
|
||||||
|
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>이전</button>
|
||||||
|
<span style={{ fontSize: 12 }}>페이지 {page} / {totalPages}</span>
|
||||||
|
<button disabled={page >= totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}>다음</button>
|
||||||
|
<span style={{ marginLeft: 12, fontSize: 12 }}>페이지 크기</span>
|
||||||
|
<select value={pageSize} onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(1); }}>
|
||||||
|
{[10, 20, 50, 100].map((s) => (<option key={s} value={s}>{s}</option>))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>닉네임</th>
|
|
||||||
<th>이름</th>
|
|
||||||
<th>전화</th>
|
|
||||||
<th>상태</th>
|
|
||||||
<th>권한</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.map((u) => (
|
|
||||||
<Row key={u.userId} u={u} onChanged={mutate} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -45,25 +89,30 @@ function Row({ u, onChanged }: { u: any; onChanged: () => void }) {
|
|||||||
}
|
}
|
||||||
const allRoles = ["admin", "editor", "user"] as const;
|
const allRoles = ["admin", "editor", "user"] as const;
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr className="hover:bg-neutral-50">
|
||||||
<td>{u.nickname}</td>
|
<td className="px-4 py-2 text-left">{u.nickname}</td>
|
||||||
<td>{u.name}</td>
|
<td className="px-4 py-2 text-left">{u.name}</td>
|
||||||
<td>{u.phone}</td>
|
<td className="px-4 py-2 text-left">{u.phone}</td>
|
||||||
<td>
|
<td className="px-4 py-2 text-right tabular-nums">{(u.points ?? 0).toLocaleString()}</td>
|
||||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
<td className="px-4 py-2 text-center">{u.level ?? 1}</td>
|
||||||
<option value="active">active</option>
|
<td className="px-4 py-2 text-center">{u.grade ?? 0}</td>
|
||||||
<option value="suspended">suspended</option>
|
<td className="px-4 py-2 text-center">
|
||||||
<option value="withdrawn">withdrawn</option>
|
<select className="h-8 px-2 border border-neutral-300 rounded-md bg-white text-sm" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||||
|
<option value="active">활성</option>
|
||||||
|
<option value="suspended">정지</option>
|
||||||
|
<option value="withdrawn">탈퇴</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="px-4 py-2">
|
||||||
{allRoles.map((r) => (
|
{allRoles.map((r) => (
|
||||||
<label key={r} style={{ marginRight: 8 }}>
|
<label key={r} className="mr-2">
|
||||||
<input type="checkbox" checked={roles.includes(r)} onChange={(e) => setRoles((prev) => (e.target.checked ? Array.from(new Set([...prev, r])) : prev.filter((x) => x !== r)))} /> {r}
|
<input type="checkbox" checked={roles.includes(r)} onChange={(e) => setRoles((prev) => (e.target.checked ? Array.from(new Set([...prev, r])) : prev.filter((x) => x !== r)))} /> {r}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</td>
|
</td>
|
||||||
<td><button onClick={save}>저장</button></td>
|
<td className="px-4 py-2 text-center">
|
||||||
|
<button className="h-8 px-3 rounded-md bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95" onClick={save}>저장</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ export async function GET() {
|
|||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
imageUrl: z.string().url(),
|
// 절대 URL 또는 /로 시작하는 상대경로 허용
|
||||||
linkUrl: z.string().url().optional(),
|
imageUrl: z.string().refine((v) => /^https?:\/\//.test(v) || v.startsWith("/"), {
|
||||||
|
message: "imageUrl must be absolute URL or start with /",
|
||||||
|
}),
|
||||||
|
linkUrl: z.string().refine((v) => !v || /^https?:\/\//.test(v), { message: "linkUrl must be http(s) URL" }).optional(),
|
||||||
active: z.boolean().optional(),
|
active: z.boolean().optional(),
|
||||||
sortOrder: z.coerce.number().int().optional(),
|
sortOrder: z.coerce.number().int().optional(),
|
||||||
startAt: z.coerce.date().optional(),
|
startAt: z.coerce.date().optional(),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
|
|||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const data: any = {};
|
const data: any = {};
|
||||||
for (const k of ["name", "slug", "description", "sortOrder", "readLevel", "writeLevel", "allowAnonymousPost", "allowSecretComment", "requiresApproval", "status", "type", "isAdultOnly", "categoryId", "mainPageViewTypeId", "listViewTypeId"]) {
|
for (const k of ["name", "slug", "description", "sortOrder", "readLevel", "writeLevel", "allowAnonymousPost", "allowSecretComment", "status", "isAdultOnly", "categoryId", "mainPageViewTypeId", "listViewTypeId"]) {
|
||||||
if (k in body) data[k] = body[k];
|
if (k in body) data[k] = body[k];
|
||||||
}
|
}
|
||||||
if ("requiredTags" in body) {
|
if ("requiredTags" in body) {
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ export async function GET() {
|
|||||||
writeLevel: true,
|
writeLevel: true,
|
||||||
allowAnonymousPost: true,
|
allowAnonymousPost: true,
|
||||||
allowSecretComment: true,
|
allowSecretComment: true,
|
||||||
requiresApproval: true,
|
|
||||||
type: true,
|
|
||||||
status: true,
|
status: true,
|
||||||
categoryId: true,
|
categoryId: true,
|
||||||
mainPageViewTypeId: true,
|
mainPageViewTypeId: true,
|
||||||
@@ -37,9 +35,7 @@ const createSchema = z.object({
|
|||||||
writeLevel: z.string().optional(),
|
writeLevel: z.string().optional(),
|
||||||
allowAnonymousPost: z.boolean().optional(),
|
allowAnonymousPost: z.boolean().optional(),
|
||||||
allowSecretComment: z.boolean().optional(),
|
allowSecretComment: z.boolean().optional(),
|
||||||
requiresApproval: z.boolean().optional(),
|
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
type: z.string().optional(),
|
|
||||||
isAdultOnly: z.boolean().optional(),
|
isAdultOnly: z.boolean().optional(),
|
||||||
categoryId: z.string().nullable().optional(),
|
categoryId: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
21
src/app/api/admin/partner-shops/[id]/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const data: any = {};
|
||||||
|
for (const k of ["region", "name", "address", "imageUrl", "active", "sortOrder"]) {
|
||||||
|
if (k in body) data[k] = body[k];
|
||||||
|
}
|
||||||
|
const item = await prisma.partnerShop.update({ where: { id }, data });
|
||||||
|
return NextResponse.json({ item });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
await prisma.partnerShop.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
27
src/app/api/admin/partner-shops/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const items = await prisma.partnerShop.findMany({ orderBy: [{ active: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }] });
|
||||||
|
return NextResponse.json({ items });
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
region: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
address: z.string().min(1),
|
||||||
|
imageUrl: z.string().min(1),
|
||||||
|
sortOrder: z.coerce.number().int().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const parsed = createSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
|
const item = await prisma.partnerShop.create({ data: parsed.data });
|
||||||
|
return NextResponse.json({ item }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
34
src/app/api/admin/partners/[id]/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const data: any = {};
|
||||||
|
for (const k of ["name", "category", "latitude", "longitude", "address", "imageUrl", "sortOrder"]) {
|
||||||
|
if (k in body) data[k] = body[k];
|
||||||
|
}
|
||||||
|
if (typeof data.latitude !== "undefined") data.latitude = Number(data.latitude);
|
||||||
|
if (typeof data.longitude !== "undefined") data.longitude = Number(data.longitude);
|
||||||
|
if (typeof data.sortOrder !== "undefined") data.sortOrder = Number(data.sortOrder);
|
||||||
|
try {
|
||||||
|
const partner = await prisma.partner.update({ where: { id }, data });
|
||||||
|
return NextResponse.json({ partner });
|
||||||
|
} catch (e) {
|
||||||
|
// DB에 sortOrder 컬럼이 아직 없는 환경 대비: 해당 키 제거 후 재시도
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data, "sortOrder")) {
|
||||||
|
const { sortOrder, ...rest } = data;
|
||||||
|
const partner = await prisma.partner.update({ where: { id }, data: rest });
|
||||||
|
return NextResponse.json({ partner });
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
await prisma.partner.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
40
src/app/api/admin/partners/route.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// 정렬용 컬럼(sortOrder)이 있는 경우 우선 사용
|
||||||
|
const partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }] });
|
||||||
|
return NextResponse.json({ partners });
|
||||||
|
} catch (_) {
|
||||||
|
// 컬럼이 아직 DB에 없으면 createdAt 기준으로 폴백
|
||||||
|
const partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" } });
|
||||||
|
return NextResponse.json({ partners });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
category: z.string().min(1),
|
||||||
|
latitude: z.coerce.number(),
|
||||||
|
longitude: z.coerce.number(),
|
||||||
|
address: z.string().min(1).optional(),
|
||||||
|
imageUrl: z
|
||||||
|
.string()
|
||||||
|
.refine((v) => !v || /^https?:\/\//.test(v) || v.startsWith("/"), {
|
||||||
|
message: "imageUrl must be http(s) URL or start with /",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
sortOrder: z.coerce.number().int().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const parsed = createSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
|
const partner = await prisma.partner.create({ data: parsed.data as any });
|
||||||
|
return NextResponse.json({ partner }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4,34 +4,46 @@ import prisma from "@/lib/prisma";
|
|||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const q = searchParams.get("q") || "";
|
const q = searchParams.get("q") || "";
|
||||||
const users = await prisma.user.findMany({
|
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
||||||
where: q
|
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10)));
|
||||||
? {
|
const where = q
|
||||||
OR: [
|
? {
|
||||||
{ nickname: { contains: q } },
|
OR: [
|
||||||
{ phone: { contains: q } },
|
{ nickname: { contains: q } },
|
||||||
{ name: { contains: q } },
|
{ phone: { contains: q } },
|
||||||
],
|
{ name: { contains: q } },
|
||||||
}
|
],
|
||||||
: {},
|
}
|
||||||
orderBy: { createdAt: "desc" },
|
: {};
|
||||||
select: {
|
|
||||||
userId: true,
|
const [total, users] = await Promise.all([
|
||||||
nickname: true,
|
prisma.user.count({ where }),
|
||||||
name: true,
|
prisma.user.findMany({
|
||||||
phone: true,
|
where,
|
||||||
status: true,
|
orderBy: { createdAt: "desc" },
|
||||||
authLevel: true,
|
skip: (page - 1) * pageSize,
|
||||||
createdAt: true,
|
take: pageSize,
|
||||||
userRoles: { select: { role: { select: { name: true } } } },
|
select: {
|
||||||
},
|
userId: true,
|
||||||
take: 100,
|
nickname: true,
|
||||||
});
|
name: true,
|
||||||
|
phone: true,
|
||||||
|
status: true,
|
||||||
|
authLevel: true,
|
||||||
|
createdAt: true,
|
||||||
|
points: true,
|
||||||
|
level: true,
|
||||||
|
grade: true,
|
||||||
|
userRoles: { select: { role: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const items = users.map((u) => ({
|
const items = users.map((u) => ({
|
||||||
...u,
|
...u,
|
||||||
roles: u.userRoles.map((r) => r.role.name),
|
roles: u.userRoles.map((r) => r.role.name),
|
||||||
}));
|
}));
|
||||||
return NextResponse.json({ users: items });
|
return NextResponse.json({ total, page, pageSize, users: items });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ export async function GET(req: Request) {
|
|||||||
name: true,
|
name: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
description: true,
|
description: true,
|
||||||
type: true,
|
|
||||||
requiresApproval: true,
|
|
||||||
allowAnonymousPost: true,
|
allowAnonymousPost: true,
|
||||||
isAdultOnly: true,
|
isAdultOnly: true,
|
||||||
category: { select: { id: true, name: true, slug: true } },
|
category: { select: { id: true, name: true, slug: true } },
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export async function GET() {
|
|||||||
boards: {
|
boards: {
|
||||||
where: { status: "active" },
|
where: { status: "active" },
|
||||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
select: { id: true, name: true, slug: true, requiresApproval: true, type: true },
|
select: { id: true, name: true, slug: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
12
src/app/api/partner-shops/route.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const items = await prisma.partnerShop.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||||
|
});
|
||||||
|
return NextResponse.json({ items });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
const { boardId, authorId, title, content, isAnonymous } = parsed.data;
|
const { boardId, authorId, title, content, isAnonymous } = parsed.data;
|
||||||
const board = await prisma.board.findUnique({ where: { id: boardId } });
|
const board = await prisma.board.findUnique({ where: { id: boardId } });
|
||||||
const requiresApproval = board?.requiresApproval ?? false;
|
|
||||||
// 사진형 보드 필수 이미지 검증: content 내 이미지 링크 최소 1개
|
// 사진형 보드 필수 이미지 검증: content 내 이미지 링크 최소 1개
|
||||||
const isImageOnly = (board?.requiredFields as any)?.imageOnly;
|
const isImageOnly = (board?.requiredFields as any)?.imageOnly;
|
||||||
const minImages = (board?.requiredFields as any)?.minImages ?? 0;
|
const minImages = (board?.requiredFields as any)?.minImages ?? 0;
|
||||||
@@ -35,7 +34,7 @@ export async function POST(req: Request) {
|
|||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
isAnonymous: !!isAnonymous,
|
isAnonymous: !!isAnonymous,
|
||||||
status: requiresApproval ? "hidden" : "published",
|
status: "published",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json({ post }, { status: 201 });
|
return NextResponse.json({ post }, { status: 201 });
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { PostList } from "@/app/components/PostList";
|
|||||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||||
import { BoardToolbar } from "@/app/components/BoardToolbar";
|
import { BoardToolbar } from "@/app/components/BoardToolbar";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
// Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다.
|
// Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다.
|
||||||
export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) {
|
export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) {
|
||||||
const p = params?.then ? await params : params;
|
const p = params?.then ? await params : params;
|
||||||
const sp = searchParams?.then ? await searchParams : searchParams;
|
const sp = searchParams?.then ? await searchParams : searchParams;
|
||||||
const id = p.id as string;
|
const idOrSlug = p.id as string;
|
||||||
const sort = (sp?.sort as "recent" | "popular" | undefined) ?? "recent";
|
const sort = (sp?.sort as "recent" | "popular" | undefined) ?? "recent";
|
||||||
// 보드 slug 조회 (새 글 페이지 프리셋 전달)
|
// 보드 slug 조회 (새 글 페이지 프리셋 전달)
|
||||||
const h = await headers();
|
const h = await headers();
|
||||||
@@ -16,29 +17,70 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
|||||||
const base = process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`;
|
const base = process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`;
|
||||||
const res = await fetch(new URL("/api/boards", base).toString(), { cache: "no-store" });
|
const res = await fetch(new URL("/api/boards", base).toString(), { cache: "no-store" });
|
||||||
const { boards } = await res.json();
|
const { boards } = await res.json();
|
||||||
const board = (boards || []).find((b: any) => b.id === id);
|
const board = (boards || []).find((b: any) => b.slug === idOrSlug || b.id === idOrSlug);
|
||||||
|
const id = board?.id as string;
|
||||||
const siblingBoards = (boards || []).filter((b: any) => b.category?.id && b.category.id === board?.category?.id);
|
const siblingBoards = (boards || []).filter((b: any) => b.category?.id && b.category.id === board?.category?.id);
|
||||||
const categoryName = board?.category?.name ?? "";
|
const categoryName = board?.category?.name ?? "";
|
||||||
|
|
||||||
|
// 리스트 뷰 타입 확인 (특수랭킹일 경우 게시글 대신 랭킹 노출)
|
||||||
|
const boardView = await prisma.board.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { listViewType: { select: { key: true } } },
|
||||||
|
});
|
||||||
|
const isSpecialRanking = boardView?.listViewType?.key === "list_special_rank";
|
||||||
|
|
||||||
|
let rankingItems: { userId: string; nickname: string; points: number }[] = [];
|
||||||
|
if (isSpecialRanking) {
|
||||||
|
const topUsers = await prisma.user.findMany({
|
||||||
|
select: { userId: true, nickname: true, points: true },
|
||||||
|
where: { status: "active" },
|
||||||
|
orderBy: { points: "desc" },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
rankingItems = topUsers.map((u) => ({ userId: u.userId, nickname: u.nickname, points: u.points }));
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 상단 배너 (서브카테고리 표시) */}
|
{/* 상단 배너 (서브카테고리 표시) */}
|
||||||
<section>
|
<section>
|
||||||
<HeroBanner
|
<HeroBanner
|
||||||
subItems={siblingBoards.map((b: any) => ({ id: b.id, name: b.name, href: `/boards/${b.id}` }))}
|
subItems={siblingBoards.map((b: any) => ({ id: b.id, name: b.name, href: `/boards/${b.slug}` }))}
|
||||||
activeSubId={id}
|
activeSubId={id}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 검색/필터 툴바 + 리스트 */}
|
{/* 검색/필터 툴바 + 리스트 */}
|
||||||
<section>
|
<section>
|
||||||
<BoardToolbar boardId={id} />
|
{!isSpecialRanking && <BoardToolbar boardId={board?.slug} />}
|
||||||
<div className="p-0">
|
<div className="p-0">
|
||||||
<PostList
|
{isSpecialRanking ? (
|
||||||
boardId={id}
|
<div className="rounded-xl border border-neutral-200 overflow-hidden">
|
||||||
sort={sort}
|
<div className="px-4 py-3 border-b border-neutral-200 flex items-center justify-between bg-[#f6f4f4]">
|
||||||
variant="board"
|
<h2 className="text-sm text-neutral-700">포인트 랭킹</h2>
|
||||||
newPostHref={`/posts/new?boardId=${id}${board?.slug ? `&boardSlug=${board.slug}` : ""}`}
|
</div>
|
||||||
/>
|
<ol className="divide-y divide-neutral-200">
|
||||||
|
{rankingItems.map((i, idx) => (
|
||||||
|
<li key={i.userId} className="px-4 py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-900 text-white text-xs">{idx + 1}</span>
|
||||||
|
<span className="truncate text-neutral-900 font-medium">{i.nickname || "회원"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-sm text-neutral-700">{i.points}점</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{rankingItems.length === 0 && (
|
||||||
|
<li className="px-4 py-10 text-center text-neutral-500">랭킹 데이터가 없습니다.</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PostList
|
||||||
|
boardId={id}
|
||||||
|
sort={sort}
|
||||||
|
variant="board"
|
||||||
|
newPostHref={`/posts/new?boardId=${id}${board?.slug ? `&boardSlug=${board.slug}` : ""}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
export default async function BoardsPage() {
|
export default async function BoardsPage() {
|
||||||
@@ -14,7 +15,7 @@ export default async function BoardsPage() {
|
|||||||
<h1>게시판</h1>
|
<h1>게시판</h1>
|
||||||
<ul style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
<ul style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
{boards?.map((b: any) => (
|
{boards?.map((b: any) => (
|
||||||
<li key={b.id}><a href={`/boards/${b.id}`}>{b.name}</a></li>
|
<li key={b.id}><Link href={`/boards/${b.slug}`}>{b.name}</Link></li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function AppHeader() {
|
|||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
const activeCategorySlug = React.useMemo(() => {
|
const activeCategorySlug = React.useMemo(() => {
|
||||||
if (activeBoardId) {
|
if (activeBoardId) {
|
||||||
const found = categories.find((c) => c.boards.some((b) => b.id === activeBoardId));
|
const found = categories.find((c) => c.boards.some((b) => b.slug === activeBoardId));
|
||||||
return found?.slug ?? null;
|
return found?.slug ?? null;
|
||||||
}
|
}
|
||||||
if (pathname === "/boards") {
|
if (pathname === "/boards") {
|
||||||
@@ -337,7 +337,7 @@ export function AppHeader() {
|
|||||||
style={idx === categories.length - 1 ? { minWidth: 120 } : undefined}
|
style={idx === categories.length - 1 ? { minWidth: 120 } : undefined}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={cat.boards?.[0]?.id ? `/boards/${cat.boards[0].id}` : `/boards?category=${cat.slug}`}
|
href={cat.boards?.[0]?.slug ? `/boards/${cat.boards[0].slug}` : `/boards?category=${cat.slug}`}
|
||||||
className={`block w-full px-2 py-2 text-sm font-medium transition-colors duration-200 hover:text-neutral-900 whitespace-nowrap ${
|
className={`block w-full px-2 py-2 text-sm font-medium transition-colors duration-200 hover:text-neutral-900 whitespace-nowrap ${
|
||||||
activeCategorySlug === cat.slug ? "text-neutral-900" : "text-neutral-700"
|
activeCategorySlug === cat.slug ? "text-neutral-900" : "text-neutral-700"
|
||||||
}`}
|
}`}
|
||||||
@@ -381,11 +381,11 @@ export function AppHeader() {
|
|||||||
{cat.boards.map((b) => (
|
{cat.boards.map((b) => (
|
||||||
<Link
|
<Link
|
||||||
key={b.id}
|
key={b.id}
|
||||||
href={`/boards/${b.id}`}
|
href={`/boards/${b.slug}`}
|
||||||
className={`rounded px-2 py-1 text-sm transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-900 text-center whitespace-nowrap ${
|
className={`rounded px-2 py-1 text-sm transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-900 text-center whitespace-nowrap ${
|
||||||
activeBoardId === b.id ? "bg-neutral-100 text-neutral-900 font-medium" : "text-neutral-700"
|
activeBoardId === b.slug ? "bg-neutral-100 text-neutral-900 font-medium" : "text-neutral-700"
|
||||||
}`}
|
}`}
|
||||||
aria-current={activeBoardId === b.id ? "page" : undefined}
|
aria-current={activeBoardId === b.slug ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{b.name}
|
{b.name}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -416,7 +416,7 @@ export function AppHeader() {
|
|||||||
<div className="mb-2 font-semibold text-neutral-800">{cat.name}</div>
|
<div className="mb-2 font-semibold text-neutral-800">{cat.name}</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{cat.boards.map((b) => (
|
{cat.boards.map((b) => (
|
||||||
<Link key={b.id} href={`/boards/${b.id}`} onClick={() => setMobileOpen(false)} className="rounded px-2 py-1 text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900">
|
<Link key={b.id} href={`/boards/${b.slug}`} onClick={() => setMobileOpen(false)} className="rounded px-2 py-1 text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900">
|
||||||
{b.name}
|
{b.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import Link from "next/link";
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
return (
|
return (
|
||||||
<aside style={{ width: 200, borderRight: "1px solid #eee", padding: 12 }}>
|
<aside style={{ width: 200, borderRight: "1px solid #eee", padding: 12 }}>
|
||||||
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
<li><a href="/boards">게시판</a></li>
|
<li><Link href="/boards">게시판</Link></li>
|
||||||
<li><a href="/admin">관리</a></li>
|
<li><Link href="/admin">관리</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function BoardToolbar({ boardId }: { boardId: string }) {
|
|||||||
const onChangeSort = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const onChangeSort = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const next = new URLSearchParams(sp.toString());
|
const next = new URLSearchParams(sp.toString());
|
||||||
next.set("sort", e.target.value);
|
next.set("sort", e.target.value);
|
||||||
router.push(`/boards/${boardId}?${next.toString()}`);
|
router.push(`/boards/${boardId}?${next.toString()}` , { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChangePeriod = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const onChangePeriod = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
@@ -26,7 +26,7 @@ export function BoardToolbar({ boardId }: { boardId: string }) {
|
|||||||
if (v === "1w") now.setDate(now.getDate() - 7);
|
if (v === "1w") now.setDate(now.getDate() - 7);
|
||||||
if (v === "1m") now.setMonth(now.getMonth() - 1);
|
if (v === "1m") now.setMonth(now.getMonth() - 1);
|
||||||
if (v === "all") next.delete("start"); else next.set("start", now.toISOString());
|
if (v === "all") next.delete("start"); else next.set("start", now.toISOString());
|
||||||
router.push(`/boards/${boardId}?${next.toString()}`);
|
router.push(`/boards/${boardId}?${next.toString()}` , { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (formData: FormData) => {
|
const onSubmit = (formData: FormData) => {
|
||||||
@@ -41,7 +41,7 @@ export function BoardToolbar({ boardId }: { boardId: string }) {
|
|||||||
next.delete("author");
|
next.delete("author");
|
||||||
if (text) next.set("q", text); else next.delete("q");
|
if (text) next.set("q", text); else next.delete("q");
|
||||||
}
|
}
|
||||||
router.push(`/boards/${boardId}?${next.toString()}`);
|
router.push(`/boards/${boardId}?${next.toString()}` , { scroll: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type ApiCategory = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
boards: { id: string; name: string; slug: string; type: string; requiresApproval: boolean }[];
|
boards: { id: string; name: string; slug: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type PostItem = {
|
type PostItem = {
|
||||||
@@ -101,7 +101,7 @@ export default function CategoryBoardBrowser({ categoryName, categorySlug }: Pro
|
|||||||
className="shrink-0 text-lg md:text-xl font-bold text-neutral-800 truncate"
|
className="shrink-0 text-lg md:text-xl font-bold text-neutral-800 truncate"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const first = selectedCategory?.boards?.[0];
|
const first = selectedCategory?.boards?.[0];
|
||||||
if (first?.id) router.push(`/boards/${first.id}`);
|
if (first?.slug) router.push(`/boards/${first.slug}`);
|
||||||
}}
|
}}
|
||||||
title={(selectedCategory?.name ?? categoryName ?? "").toString()}
|
title={(selectedCategory?.name ?? categoryName ?? "").toString()}
|
||||||
>
|
>
|
||||||
@@ -113,7 +113,7 @@ export default function CategoryBoardBrowser({ categoryName, categorySlug }: Pro
|
|||||||
className="shrink-0 w-6 h-6 rounded-full border border-neutral-300 text-neutral-500 hover:bg-neutral-50 flex items-center justify-center"
|
className="shrink-0 w-6 h-6 rounded-full border border-neutral-300 text-neutral-500 hover:bg-neutral-50 flex items-center justify-center"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const first = selectedCategory?.boards?.[0];
|
const first = selectedCategory?.boards?.[0];
|
||||||
if (first?.id) router.push(`/boards/${first.id}`);
|
if (first?.slug) router.push(`/boards/${first.slug}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
|||||||
@@ -2,12 +2,22 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { SelectedBanner } from "@/app/components/SelectedBanner";
|
import { SelectedBanner } from "@/app/components/SelectedBanner";
|
||||||
|
import Link from "next/link";
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
type Banner = { id: string; title: string; imageUrl: string; linkUrl?: string | null };
|
type Banner = { id: string; title: string; imageUrl: string; linkUrl?: string | null };
|
||||||
type SubItem = { id: string; name: string; href: string };
|
type SubItem = { id: string; name: string; href: string };
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; activeSubId?: string }) {
|
export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; activeSubId?: string }) {
|
||||||
const [banners, setBanners] = useState<Banner[]>([]);
|
// SWR 캐시 사용: 페이지 전환 시 재요청/깜빡임 최소화
|
||||||
|
const { data } = useSWR<{ banners: Banner[] }>("/api/banners", fetcher, {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 10 * 60 * 1000, // 10분 간 동일 요청 합치기
|
||||||
|
keepPreviousData: true,
|
||||||
|
});
|
||||||
|
const banners = data?.banners ?? [];
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [progress, setProgress] = useState(0); // 0..1
|
const [progress, setProgress] = useState(0); // 0..1
|
||||||
@@ -15,10 +25,6 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
|
|||||||
const rafIdRef = useRef<number | null>(null);
|
const rafIdRef = useRef<number | null>(null);
|
||||||
const startedAtRef = useRef<number>(0);
|
const startedAtRef = useRef<number>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/api/banners").then((r) => r.json()).then((d) => setBanners(d.banners ?? []));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const numSlides = banners.length;
|
const numSlides = banners.length;
|
||||||
const canAutoPlay = numSlides > 1 && !isHovered;
|
const canAutoPlay = numSlides > 1 && !isHovered;
|
||||||
|
|
||||||
@@ -67,7 +73,33 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
|
|||||||
|
|
||||||
const translatePercent = useMemo(() => (numSlides > 0 ? -(activeIndex * 100) : 0), [activeIndex, numSlides]);
|
const translatePercent = useMemo(() => (numSlides > 0 ? -(activeIndex * 100) : 0), [activeIndex, numSlides]);
|
||||||
|
|
||||||
if (numSlides === 0) return <SelectedBanner height={224} />;
|
if (numSlides === 0) {
|
||||||
|
return (
|
||||||
|
<section className="relative w-full overflow-hidden rounded-xl bg-neutral-900 text-white" aria-roledescription="carousel">
|
||||||
|
<SelectedBanner className="h-56 sm:h-72 md:h-[264px]" />
|
||||||
|
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 (스켈레톤 상태에서도 동일 레이아웃 유지) */}
|
||||||
|
<div className="mt-2 h-[58px] bg-black rounded-xl flex items-center justify-center px-2">
|
||||||
|
{Array.isArray(subItems) && subItems.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-[8px]">
|
||||||
|
{subItems.map((s) => (
|
||||||
|
<Link
|
||||||
|
key={s.id}
|
||||||
|
href={s.href}
|
||||||
|
className={
|
||||||
|
s.id === activeSubId
|
||||||
|
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
|
||||||
|
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:text-white border border-white/30 text-[12px] leading-[28px] whitespace-nowrap"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -100,7 +132,7 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
|
|||||||
|
|
||||||
{/* Pagination - Figma 스타일: 활성은 주황 바, 비활성은 회색 점 (배너 위에 오버랩) */}
|
{/* Pagination - Figma 스타일: 활성은 주황 바, 비활성은 회색 점 (배너 위에 오버랩) */}
|
||||||
{numSlides > 1 && (
|
{numSlides > 1 && (
|
||||||
<div className="pointer-events-auto absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-[6px]">
|
<div className="pointer-events-auto absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-[10px]">
|
||||||
{banners.map((_, i) => (
|
{banners.map((_, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
@@ -109,8 +141,8 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
|
|||||||
onClick={() => goTo(i)}
|
onClick={() => goTo(i)}
|
||||||
className={
|
className={
|
||||||
activeIndex === i
|
activeIndex === i
|
||||||
? "h-[4px] w-[18px] rounded-full bg-[#F94B37]"
|
? "h-[6px] w-[24px] rounded-full bg-[#F94B37]"
|
||||||
: "h-[6px] w-[6px] rounded-full bg-[rgba(255,255,255,0.6)] hover:bg-white"
|
: "h-[10px] w-[10px] rounded-full bg-[rgba(255,255,255,0.7)] hover:bg-white"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -121,11 +153,11 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
|
|||||||
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 */}
|
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 */}
|
||||||
<div className="mt-2 h-[58px] bg-black rounded-xl flex items-center justify-center px-2">
|
<div className="mt-2 h-[58px] bg-black rounded-xl flex items-center justify-center px-2">
|
||||||
{Array.isArray(subItems) && subItems.length > 0 && (
|
{Array.isArray(subItems) && subItems.length > 0 && (
|
||||||
<div className="flex items-center gap-[8px] overflow-x-auto no-scrollbar">
|
<div className="flex flex-wrap items-center gap-[8px]">
|
||||||
{subItems.map((s) => (
|
{subItems.map((s) => (
|
||||||
<a
|
<Link
|
||||||
key={s.id}
|
key={s.id}
|
||||||
href={s.href}
|
href={s.href}
|
||||||
className={
|
className={
|
||||||
s.id === activeSubId
|
s.id === activeSubId
|
||||||
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
|
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
|
||||||
@@ -133,7 +165,7 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{s.name}
|
{s.name}
|
||||||
</a>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from "next/link";
|
||||||
"use client";
|
"use client";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ export function PersonalWidgets() {
|
|||||||
<ul style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
<ul style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
{(recent?.items ?? []).map((i) => (
|
{(recent?.items ?? []).map((i) => (
|
||||||
<li key={i.id}>
|
<li key={i.id}>
|
||||||
<a href={`/posts/${i.id}`}>{i.title}</a>
|
<Link href={`/posts/${i.id}`}>{i.title}</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{(!recent || recent.items.length === 0) && <li>최근 본 글이 없습니다.</li>}
|
{(!recent || recent.items.length === 0) && <li>최근 본 글이 없습니다.</li>}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import useSWRInfinite from "swr/infinite";
|
import useSWRInfinite from "swr/infinite";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import Link from "next/link";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import ViewsIcon from "@/app/svgs/ViewsIcon";
|
import ViewsIcon from "@/app/svgs/ViewsIcon";
|
||||||
import LikeIcon from "@/app/svgs/LikeIcon";
|
import LikeIcon from "@/app/svgs/LikeIcon";
|
||||||
import CommentIcon from "@/app/svgs/CommentIcon";
|
import CommentIcon from "@/app/svgs/CommentIcon";
|
||||||
@@ -30,8 +31,9 @@ const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|||||||
|
|
||||||
export function PostList({ boardId, sort = "recent", q, tag, author, start, end, variant = "default", newPostHref }: { boardId?: string; sort?: "recent" | "popular"; q?: string; tag?: string; author?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string }) {
|
export function PostList({ boardId, sort = "recent", q, tag, author, start, end, variant = "default", newPostHref }: { boardId?: string; sort?: "recent" | "popular"; q?: string; tag?: string; author?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string }) {
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
const router = useRouter();
|
|
||||||
const sp = useSearchParams();
|
const sp = useSearchParams();
|
||||||
|
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [lockedMinHeight, setLockedMinHeight] = useState<number | null>(null);
|
||||||
|
|
||||||
const getKey = (index: number, prev: Resp | null) => {
|
const getKey = (index: number, prev: Resp | null) => {
|
||||||
if (prev && prev.items.length === 0) return null;
|
if (prev && prev.items.length === 0) return null;
|
||||||
@@ -75,6 +77,21 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
|
|||||||
const items = variant === "board" ? itemsSingle : itemsInfinite;
|
const items = variant === "board" ? itemsSingle : itemsInfinite;
|
||||||
const isEmpty = variant === "board" ? isEmptySingle : isEmptyInfinite;
|
const isEmpty = variant === "board" ? isEmptySingle : isEmptyInfinite;
|
||||||
|
|
||||||
|
// 잠깐 높이 고정: 페이지 변경으로 재로딩될 때 현재 높이를 min-height로 유지
|
||||||
|
const lockHeight = () => {
|
||||||
|
const el = listContainerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const h = el.offsetHeight;
|
||||||
|
if (h > 0) setLockedMinHeight(h);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 로딩이 끝나면 해제
|
||||||
|
useEffect(() => {
|
||||||
|
if (variant === "board" && !isLoadingSingle) {
|
||||||
|
setLockedMinHeight(null);
|
||||||
|
}
|
||||||
|
}, [variant, isLoadingSingle]);
|
||||||
|
|
||||||
const initials = (name?: string | null) => (name ? name.trim().slice(0, 1) : "익");
|
const initials = (name?: string | null) => (name ? name.trim().slice(0, 1) : "익");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -121,22 +138,23 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 아이템들 */}
|
{/* 아이템들 */}
|
||||||
<ul className="divide-y divide-[#ececec]">
|
<div ref={listContainerRef} style={{ minHeight: lockedMinHeight ? `${lockedMinHeight}px` : undefined }}>
|
||||||
{items.map((p) => (
|
<ul className="divide-y divide-[#ececec]">
|
||||||
<li key={p.id} className={`px-4 ${variant === "board" ? "py-2.5" : "py-3 md:py-3"} hover:bg-neutral-50 transition-colors`}>
|
{items.map((p) => (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-[20px_1fr_120px_120px_80px] items-center gap-2">
|
<li key={p.id} className={`px-4 ${variant === "board" ? "py-2.5" : "py-3 md:py-3"} hover:bg-neutral-50 transition-colors`}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-[20px_1fr_120px_120px_80px] items-center gap-2">
|
||||||
{/* bullet/공지 아이콘 자리 */}
|
{/* bullet/공지 아이콘 자리 */}
|
||||||
<div className="hidden md:flex items-center justify-center text-[#f94b37]">{p.isPinned ? "★" : "•"}</div>
|
<div className="hidden md:flex items-center justify-center text-[#f94b37]">{p.isPinned ? "★" : "•"}</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<a href={`/posts/${p.id}`} className="block truncate text-[15px] md:text-base text-neutral-900">
|
<Link href={`/posts/${p.id}`} className="block truncate text-[15px] md:text-base text-neutral-900">
|
||||||
{p.isPinned && <span className="mr-2 inline-flex items-center rounded bg-orange-100 text-orange-700 px-1.5 py-0.5 text-[11px]">공지</span>}
|
{p.isPinned && <span className="mr-2 inline-flex items-center rounded bg-orange-100 text-orange-700 px-1.5 py-0.5 text-[11px]">공지</span>}
|
||||||
{p.title}
|
{p.title}
|
||||||
</a>
|
</Link>
|
||||||
{!!p.postTags?.length && (
|
{!!p.postTags?.length && (
|
||||||
<div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500">
|
<div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500">
|
||||||
{p.postTags?.map((pt) => (
|
{p.postTags?.map((pt) => (
|
||||||
<a key={pt.tag.slug} href={`/search?tag=${pt.tag.slug}`} className="hover:underline">#{pt.tag.name}</a>
|
<Link key={pt.tag.slug} href={`/search?tag=${pt.tag.slug}`} className="hover:underline">#{pt.tag.name}</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -152,9 +170,10 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
|
|||||||
</div>
|
</div>
|
||||||
<div className="md:w-[80px] text-xs text-neutral-500 text-right">{new Date(p.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
|
<div className="md:w-[80px] text-xs text-neutral-500 text-right">{new Date(p.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{!isEmpty && (
|
{!isEmpty && (
|
||||||
@@ -164,11 +183,14 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
|
|||||||
{/* Previous */}
|
{/* Previous */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
lockHeight();
|
||||||
const next = Math.max(1, page - 1);
|
const next = Math.max(1, page - 1);
|
||||||
setPage(next);
|
setPage(next);
|
||||||
const nextSp = new URLSearchParams(Array.from(sp.entries()));
|
const nextSp = new URLSearchParams(Array.from(sp.entries()));
|
||||||
nextSp.set("page", String(next));
|
nextSp.set("page", String(next));
|
||||||
router.push(`?${nextSp.toString()}`);
|
if (typeof window !== "undefined") {
|
||||||
|
window.history.replaceState(null, "", `?${nextSp.toString()}`);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50"
|
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50"
|
||||||
@@ -195,10 +217,13 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
|
|||||||
<button
|
<button
|
||||||
key={`p-${n}-${idx}`}
|
key={`p-${n}-${idx}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
lockHeight();
|
||||||
setPage(n);
|
setPage(n);
|
||||||
const nextSp = new URLSearchParams(Array.from(sp.entries()));
|
const nextSp = new URLSearchParams(Array.from(sp.entries()));
|
||||||
nextSp.set("page", String(n));
|
nextSp.set("page", String(n));
|
||||||
router.push(`?${nextSp.toString()}`);
|
if (typeof window !== "undefined") {
|
||||||
|
window.history.replaceState(null, "", `?${nextSp.toString()}`);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
aria-current={n === page ? "page" : undefined}
|
aria-current={n === page ? "page" : undefined}
|
||||||
className={`h-9 w-9 rounded-md border ${n === page ? "border-neutral-300 text-[#f94b37] font-semibold" : "border-neutral-300 text-neutral-900"}`}
|
className={`h-9 w-9 rounded-md border ${n === page ? "border-neutral-300 text-[#f94b37] font-semibold" : "border-neutral-300 text-neutral-900"}`}
|
||||||
@@ -214,11 +239,14 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
|
|||||||
{/* Next */}
|
{/* Next */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
lockHeight();
|
||||||
const next = Math.min(totalPages, page + 1);
|
const next = Math.min(totalPages, page + 1);
|
||||||
setPage(next);
|
setPage(next);
|
||||||
const nextSp = new URLSearchParams(Array.from(sp.entries()));
|
const nextSp = new URLSearchParams(Array.from(sp.entries()));
|
||||||
nextSp.set("page", String(next));
|
nextSp.set("page", String(next));
|
||||||
router.push(`?${nextSp.toString()}`);
|
if (typeof window !== "undefined") {
|
||||||
|
window.history.replaceState(null, "", `?${nextSp.toString()}`);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50"
|
className="h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm disabled:opacity-50"
|
||||||
@@ -227,9 +255,9 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{newPostHref && (
|
{newPostHref && (
|
||||||
<a href={newPostHref} className="shrink-0">
|
<Link href={newPostHref} className="shrink-0">
|
||||||
<button className="h-9 px-4 rounded-[10px] bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95">글쓰기</button>
|
<button className="h-9 px-4 rounded-[10px] bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95">글쓰기</button>
|
||||||
</a>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from "next/link";
|
||||||
"use client";
|
"use client";
|
||||||
import { usePermission } from "@/lib/usePermission";
|
import { usePermission } from "@/lib/usePermission";
|
||||||
|
|
||||||
@@ -7,8 +8,8 @@ export function QuickActions() {
|
|||||||
const isAdmin = can("ADMIN", "ADMINISTER") || can("BOARD", "MODERATE");
|
const isAdmin = can("ADMIN", "ADMINISTER") || can("BOARD", "MODERATE");
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
{canWrite && <a href="/posts/new"><button>글쓰기</button></a>}
|
{canWrite && <Link href="/posts/new"><button>글쓰기</button></Link>}
|
||||||
{isAdmin && <a href="/admin"><button>관리자</button></a>}
|
{isAdmin && <Link href="/admin"><button>관리자</button></Link>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,24 +2,19 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
height?: number | string; // ex) 224 or '14rem'
|
height?: number | string; // ex) 224 or '14rem' (지정 시 고정 높이, 미지정 시 클래스 높이 사용)
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Figma 선택 요소(Container_Event)를 그대로 옮긴 플레이스홀더형 배너
|
// Figma 선택 요소(Container_Event)를 그대로 옮긴 플레이스홀더형 배너
|
||||||
export function SelectedBanner({ height = 122, className }: Props) {
|
export function SelectedBanner({ height, className }: Props) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`relative w-full overflow-hidden rounded-[12px] bg-[#D9D9D9] ${className ?? ""}`}
|
className={`relative w-full overflow-hidden rounded-[12px] bg-[#D9D9D9] ${className ?? ""}`}
|
||||||
style={{ height }}
|
style={height != null ? { height } : undefined}
|
||||||
aria-label="banner"
|
aria-label="banner"
|
||||||
>
|
>
|
||||||
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-[6px]" style={{ bottom: 12 }}>
|
{/* 스켈레톤 상태에서는 하단 점(페이지네이션) 제거 */}
|
||||||
<span className="block h-[4px] w-[18px] rounded-full bg-[#F94B37]" aria-hidden />
|
|
||||||
{Array.from({ length: 12 }).map((_, i) => (
|
|
||||||
<span key={i} className="block h-[6px] w-[6px] rounded-full bg-[#B9B9B9]" aria-hidden />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from "next/link";
|
||||||
"use client";
|
"use client";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
@@ -8,11 +9,11 @@ export function TagFilter({ basePath, current }: { basePath: string; current?: s
|
|||||||
const tags = data?.tags ?? [];
|
const tags = data?.tags ?? [];
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 8 }}>
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 8 }}>
|
||||||
<a href={`${basePath}`} style={{ textDecoration: !current ? "underline" : "none" }}>전체</a>
|
<Link href={`${basePath}`} style={{ textDecoration: !current ? "underline" : "none" }}>전체</Link>
|
||||||
{tags.map((t) => (
|
{tags.map((t) => (
|
||||||
<a key={t.slug} href={`${basePath}?tag=${t.slug}`} style={{ textDecoration: current === t.slug ? "underline" : "none" }}>
|
<Link key={t.slug} href={`${basePath}?tag=${t.slug}`} style={{ textDecoration: current === t.slug ? "underline" : "none" }}>
|
||||||
#{t.name}
|
#{t.name}
|
||||||
</a>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ export function Modal({ open, onClose, children }: Props) {
|
|||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
background: "rgba(0,0,0,0.4)",
|
background: "transparent",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
zIndex: 50,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div onClick={(e) => e.stopPropagation()} style={{ background: "#fff", padding: 16, borderRadius: 8 }}>
|
<div onClick={(e) => e.stopPropagation()} style={{ background: "#fff", padding: 16, borderRadius: 8 }}>
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ body {
|
|||||||
font-family: Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif;
|
font-family: Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 레이아웃 점프 방지: 스크롤바 여백을 항상 확보 */
|
||||||
|
html { scrollbar-gutter: stable both-edges; }
|
||||||
|
|
||||||
/* 유틸: 카드 스켈레톤 색상 헬퍼 (타깃 사이트 톤 유사) */
|
/* 유틸: 카드 스켈레톤 색상 헬퍼 (타깃 사이트 톤 유사) */
|
||||||
.bg-neutral-100 { background-color: #f5f5f7; }
|
.bg-neutral-100 { background-color: #f5f5f7; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from "next/link";
|
||||||
"use client";
|
"use client";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "@/app/components/ui/Button";
|
import { Button } from "@/app/components/ui/Button";
|
||||||
@@ -46,7 +47,7 @@ export default function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
<Button type="submit" disabled={loading}>{loading ? "로그인 중..." : "로그인"}</Button>
|
<Button type="submit" disabled={loading}>{loading ? "로그인 중..." : "로그인"}</Button>
|
||||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||||
<a href="/register">회원가입</a>
|
<Link href="/register">회원가입</Link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
203
src/app/page.tsx
@@ -1,4 +1,5 @@
|
|||||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||||
|
import Link from "next/link";
|
||||||
import HorizontalCardScroller from "@/app/components/HorizontalCardScroller";
|
import HorizontalCardScroller from "@/app/components/HorizontalCardScroller";
|
||||||
import { PostList } from "@/app/components/PostList";
|
import { PostList } from "@/app/components/PostList";
|
||||||
import ProfileLabelIcon from "@/app/svgs/profilelableicon";
|
import ProfileLabelIcon from "@/app/svgs/profilelableicon";
|
||||||
@@ -17,28 +18,146 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
|||||||
const showPartnerShops: boolean = parsed.showPartnerShops ?? true;
|
const showPartnerShops: boolean = parsed.showPartnerShops ?? true;
|
||||||
const visibleBoardIds: string[] = Array.isArray(parsed.visibleBoardIds) ? parsed.visibleBoardIds : [];
|
const visibleBoardIds: string[] = Array.isArray(parsed.visibleBoardIds) ? parsed.visibleBoardIds : [];
|
||||||
|
|
||||||
// 보드 메타데이터 (이름 표시용)
|
// 보드 메타데이터 (메인뷰 타입 포함)
|
||||||
const boardsMeta = visibleBoardIds.length
|
const boardsMeta = visibleBoardIds.length
|
||||||
? await prisma.board.findMany({ where: { id: { in: visibleBoardIds } }, select: { id: true, name: true } })
|
? await prisma.board.findMany({
|
||||||
|
where: { id: { in: visibleBoardIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
category: { select: { id: true, name: true } },
|
||||||
|
mainPageViewType: { select: { key: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
: [];
|
: [];
|
||||||
const idToMeta = new Map(boardsMeta.map((b) => [b.id, b] as const));
|
const idToMeta = new Map(
|
||||||
|
boardsMeta.map((b) => [
|
||||||
|
b.id,
|
||||||
|
{
|
||||||
|
id: b.id,
|
||||||
|
name: b.name,
|
||||||
|
slug: b.slug,
|
||||||
|
categoryId: b.category?.id ?? null,
|
||||||
|
categoryName: b.category?.name ?? "",
|
||||||
|
mainTypeKey: b.mainPageViewType?.key,
|
||||||
|
},
|
||||||
|
] as const)
|
||||||
|
);
|
||||||
const orderedBoards = visibleBoardIds
|
const orderedBoards = visibleBoardIds
|
||||||
.map((id) => idToMeta.get(id))
|
.map((id) => idToMeta.get(id))
|
||||||
.filter((v): v is { id: string; name: string } => Boolean(v));
|
.filter((v): v is any => Boolean(v));
|
||||||
const firstTwo = orderedBoards.slice(0, 2);
|
const firstTwo = orderedBoards.slice(0, 2);
|
||||||
const restBoards = orderedBoards.slice(2);
|
const restBoards = orderedBoards.slice(2);
|
||||||
|
|
||||||
const renderBoardPanel = (board: { id: string; name: string }) => (
|
function formatDateYmd(d: Date) {
|
||||||
<div key={board.id} className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col bg-white">
|
const yyyy = d.getFullYear();
|
||||||
<div className="px-3 py-2 border-b border-neutral-200 flex items-center justify-between">
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
<a href={`/boards/${board.id}`} className="text-lg md:text-xl font-bold text-neutral-800 truncate">{board.name}</a>
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
<a href={`/boards/${board.id}`} className="text-xs px-3 py-1 rounded-full border border-neutral-300 text-neutral-700 hover:bg-neutral-100">더보기</a>
|
return `${yyyy}.${mm}.${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderBoardPanel = async (board: { id: string; name: string; slug: string; categoryId: string | null; categoryName: string; mainTypeKey?: string }) => {
|
||||||
|
// 같은 카테고리의 소분류(게시판) 탭 목록
|
||||||
|
const siblingBoards = board.categoryId
|
||||||
|
? await prisma.board.findMany({
|
||||||
|
where: { categoryId: board.categoryId },
|
||||||
|
select: { id: true, name: true, slug: true },
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const isTextMain = board.mainTypeKey === "main_text";
|
||||||
|
if (!isTextMain) {
|
||||||
|
return (
|
||||||
|
<div key={board.id} className="h-full min-h-0 flex flex-col">
|
||||||
|
<div className="content-stretch flex gap-[30px] items-start w-full mb-2">
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
<div className="text-[30px] text-[#5c5c5c] leading-[30px]">{board.categoryName || board.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
{siblingBoards.map((sb) => (
|
||||||
|
<Link
|
||||||
|
key={sb.id}
|
||||||
|
href={`/boards/${sb.slug}`}
|
||||||
|
className={`px-[16px] py-[8px] rounded-[14px] text-[14px] ${
|
||||||
|
sb.id === board.id ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sb.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col bg-white">
|
||||||
|
<div className="px-3 py-2 border-b border-neutral-200 flex items-center justify-between">
|
||||||
|
<Link href={`/boards/${board.slug}`} className="text-lg md:text-xl font-bold text-neutral-800 truncate">{board.name}</Link>
|
||||||
|
<Link href={`/boards/${board.slug}`} className="text-xs px-3 py-1 rounded-full border border-neutral-300 text-neutral-700 hover:bg-neutral-100">더보기</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden p-0">
|
||||||
|
<PostList boardId={board.id} sort={sort} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = await prisma.post.findMany({
|
||||||
|
where: { boardId: board.id, status: "published" },
|
||||||
|
select: { id: true, title: true, createdAt: true, stat: { select: { recommendCount: true } } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={board.id} className="h-full min-h-0 flex flex-col">
|
||||||
|
<div className="content-stretch flex gap-[30px] items-start w-full mb-2">
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
<div className="text-[30px] text-[#5c5c5c] leading-[30px]">{board.categoryName || board.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
{siblingBoards.map((sb) => (
|
||||||
|
<Link
|
||||||
|
key={sb.id}
|
||||||
|
href={`/boards/${sb.slug}`}
|
||||||
|
className={`px-[16px] py-[8px] rounded-[14px] text-[14px] ${
|
||||||
|
sb.id === board.id ? "bg-[#5c5c5c] text-white border border-[#5c5c5c]" : "bg-white text-[#5c5c5c] border border-[#d5d5d5]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sb.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col bg-white">
|
||||||
|
<div className="px-3 py-2 border-b border-neutral-200 flex items-center justify-between">
|
||||||
|
<Link href={`/boards/${board.slug}`} className="text-lg md:text-xl font-bold text-neutral-800 truncate">{board.name}</Link>
|
||||||
|
<Link href={`/boards/${board.slug}`} className="text-xs px-3 py-1 rounded-full border border-neutral-300 text-neutral-700 hover:bg-neutral-100">더보기</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden p-0">
|
||||||
|
<div className="bg-white px-[24px] pt-[8px] pb-[16px]">
|
||||||
|
<ul className="min-h-[326px]">
|
||||||
|
{posts.map((p) => (
|
||||||
|
<li key={p.id} className="border-b border-[#ededed] h-[56px] pl-0 pr-[24px] pt-[16px] pb-[16px]">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center gap-[4px] h-[24px] overflow-hidden">
|
||||||
|
<div className="relative w-[16px] h-[16px] shrink-0">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-[#f45f00] opacity-0" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate max-w-[calc(100vw-280px)]">{p.title}</span>
|
||||||
|
<span className="text-[16px] text-[#f45f00] font-bold">+{p.stat?.recommendCount ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]">{formatDateYmd(new Date(p.createdAt))}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 overflow-hidden p-0">
|
);
|
||||||
<PostList boardId={board.id} sort={sort} />
|
};
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -49,26 +168,34 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 제휴 샾 가로 스크롤 (설정 온오프) */}
|
{/* 제휴 샾 가로 스크롤 (설정 온오프, DB에서 불러오기)
|
||||||
{showPartnerShops && (() => {
|
- 우선 partners 테이블(관리자 페이지 관리 대상) 사용
|
||||||
const items = [
|
- 없으면 partner_shops로 대체 */}
|
||||||
{ id: 1, region: "경기도", name: "라온마사지샾", address: "수원시 팔달구 매산로 45", image: "/sample.jpg" },
|
{showPartnerShops && (async () => {
|
||||||
{ id: 2, region: "강원도", name: "휴앤힐링마사지샾", address: "춘천시 중앙로 112", image: "/sample.jpg" },
|
// 우선순위: partners(관리자 관리) → partner_shops(폴백)
|
||||||
{ id: 3, region: "충청북도", name: "소담마사지샾", address: "청주시 상당구 상당로 88", image: "/sample.jpg" },
|
let partners: any[] = [];
|
||||||
{ id: 4, region: "충청남도", name: "아늑마사지샾", address: "천안시 동남구 시민로 21", image: "/sample.jpg" },
|
try {
|
||||||
{ id: 5, region: "전라북도", name: "편안한마사지샾", address: "전주시 완산구 풍남문로 77", image: "/sample.jpg" },
|
partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], take: 10 });
|
||||||
{ id: 6, region: "전라남도", name: "바른마사지샾", address: "여수시 중앙로 9", image: "/sample.jpg" },
|
} catch (_) {
|
||||||
{ id: 7, region: "경상북도", name: "늘봄마사지샾", address: "대구시 중구 동성로3길 12", image: "/sample.jpg" },
|
partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" }, take: 10 });
|
||||||
{ id: 8, region: "경상남도", name: "편히쉬다마사지샾", address: "창원시 성산구 중앙대로 150", image: "/sample.jpg" },
|
}
|
||||||
{ id: 9, region: "제주특별자치도", name: "제주소풍마사지샾", address: "제주시 중앙로 230", image: "/sample.jpg" },
|
const items = partners.map((p: any) => ({
|
||||||
{ id: 10, region: "서울특별시", name: "도심휴식마사지샾", address: "강남구 테헤란로 427", image: "/sample.jpg" },
|
id: p.id,
|
||||||
];
|
region: p.address ? String(p.address).split(" ")[0] : p.category,
|
||||||
return <HorizontalCardScroller items={items} />;
|
name: p.name,
|
||||||
|
address: p.address || "",
|
||||||
|
image: p.imageUrl || "/sample.jpg",
|
||||||
|
}));
|
||||||
|
if (items.length > 0) return <HorizontalCardScroller items={items} />;
|
||||||
|
|
||||||
|
const shops = await (prisma as any).partnerShop.findMany({ where: { active: true }, orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }] });
|
||||||
|
const shopItems = shops.map((s: any) => ({ id: s.id, region: s.region, name: s.name, address: s.address, image: s.imageUrl }));
|
||||||
|
return <HorizontalCardScroller items={shopItems} />;
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* 1행: 프로필 + 선택된 보드 2개 (최대 2개) */}
|
{/* 1행: 프로필 + 선택된 보드 2개 (최대 2개) */}
|
||||||
{(firstTwo.length > 0) && (
|
{(firstTwo.length > 0) && (
|
||||||
<section className="min-h-[514px] overflow-hidden">
|
<section className="min-h-[410px] overflow-hidden">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:[grid-template-columns:1fr_2fr_2fr] gap-4 h-full min-h-0">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:[grid-template-columns:1fr_2fr_2fr] gap-4 h-full min-h-0">
|
||||||
<div className="hidden xl:grid relative overflow-hidden rounded-xl bg-white px-[25px] py-[34px] grid-rows-[120px_120px_1fr] gap-y-[32px] h-full w-full md:min-w-[350px]">
|
<div className="hidden xl:grid relative overflow-hidden rounded-xl bg-white px-[25px] py-[34px] grid-rows-[120px_120px_1fr] gap-y-[32px] h-full w-full md:min-w-[350px]">
|
||||||
<div className="absolute inset-x-0 top-0 h-[56px] bg-[#d5d5d5] z-0" />
|
<div className="absolute inset-x-0 top-0 h-[56px] bg-[#d5d5d5] z-0" />
|
||||||
@@ -135,9 +262,9 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{firstTwo.map((b) => (
|
{(await Promise.all(firstTwo.map((b) => renderBoardPanel(b)))).map((panel, idx) => (
|
||||||
<div key={b.id} className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
|
<div key={firstTwo[idx].id} className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
|
||||||
{renderBoardPanel(b)}
|
{panel}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -147,14 +274,14 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
|||||||
{/* 나머지 보드: 2개씩 다음 열로 렌더링 */}
|
{/* 나머지 보드: 2개씩 다음 열로 렌더링 */}
|
||||||
{restBoards.length > 0 && (
|
{restBoards.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{Array.from({ length: Math.ceil(restBoards.length / 2) }).map((_, i) => {
|
{Array.from({ length: Math.ceil(restBoards.length / 2) }).map(async (_, i) => {
|
||||||
const pair = restBoards.slice(i * 2, i * 2 + 2);
|
const pair = restBoards.slice(i * 2, i * 2 + 2);
|
||||||
return (
|
return (
|
||||||
<section key={`rest-${i}`} className="min-h-[514px] md:h-[620px] overflow-hidden">
|
<section key={`rest-${i}`} className="min-h-[410px] md:h-[510px] overflow-hidden">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 h-full min-h-0">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 h-full min-h-0">
|
||||||
{pair.map((b) => (
|
{(await Promise.all(pair.map((b) => renderBoardPanel(b)))).map((panel, idx) => (
|
||||||
<div key={b.id} className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
|
<div key={pair[idx].id} className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
|
||||||
{renderBoardPanel(b)}
|
{panel}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { HeroBanner } from "@/app/components/HeroBanner";
|
|||||||
|
|
||||||
export default function EditPostPage() {
|
export default function EditPostPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const id = params?.id as string;
|
const id = params?.id as string; // slug 값
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { show } = useToast();
|
const { show } = useToast();
|
||||||
const [form, setForm] = useState<{ title: string; content: string } | null>(null);
|
const [form, setForm] = useState<{ title: string; content: string } | null>(null);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { HeroBanner } from "@/app/components/HeroBanner";
|
|||||||
// 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다.
|
// 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다.
|
||||||
export default async function PostDetail({ params }: { params: any }) {
|
export default async function PostDetail({ params }: { params: any }) {
|
||||||
const p = params?.then ? await params : params;
|
const p = params?.then ? await params : params;
|
||||||
const id = p.id as string;
|
const id = p.id as string; // slug 값이 들어옴
|
||||||
const h = await headers();
|
const h = await headers();
|
||||||
const host = h.get("host") ?? "localhost:3000";
|
const host = h.get("host") ?? "localhost:3000";
|
||||||
const proto = h.get("x-forwarded-proto") ?? "http";
|
const proto = h.get("x-forwarded-proto") ?? "http";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from "next/link";
|
||||||
"use client";
|
"use client";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
@@ -10,10 +11,10 @@ export default function RankingPage({ searchParams }: { searchParams?: { period?
|
|||||||
<div>
|
<div>
|
||||||
<h1>회원랭킹</h1>
|
<h1>회원랭킹</h1>
|
||||||
<div style={{ display: "flex", gap: 8, margin: "8px 0" }}>
|
<div style={{ display: "flex", gap: 8, margin: "8px 0" }}>
|
||||||
<a href={`/ranking?period=daily`}>일간</a>
|
<Link href={`/ranking?period=daily`}>일간</Link>
|
||||||
<a href={`/ranking?period=weekly`}>주간</a>
|
<Link href={`/ranking?period=weekly`}>주간</Link>
|
||||||
<a href={`/ranking?period=monthly`}>월간</a>
|
<Link href={`/ranking?period=monthly`}>월간</Link>
|
||||||
<a href={`/ranking?period=all`}>전체</a>
|
<Link href={`/ranking?period=all`}>전체</Link>
|
||||||
</div>
|
</div>
|
||||||
<ol>
|
<ol>
|
||||||
{(data?.items ?? []).map((i) => (
|
{(data?.items ?? []).map((i) => (
|
||||||
|
|||||||