Compare commits

...

11 Commits

Author SHA1 Message Date
koreacomp5
e0aacab1d1 Merge branch 'mainwork' 2025-11-02 07:02:27 +09:00
koreacomp5
58af463585 ㅇㅇ 2025-11-02 07:01:42 +09:00
koreacomp5
d54ad82095 Merge branch 'subwork' into mainwork 2025-11-02 04:59:21 +09:00
koreacomp5
9c28d50890 main 2025-11-02 04:59:09 +09:00
koreacomp5
0bf270d884 sub 2025-11-02 04:59:04 +09:00
koreacomp5
c6e60cd34d Merge branch 'mainwork' into subwork 2025-11-02 04:40:04 +09:00
koreacomp5
c7f7492b9e Merge branch 'subwork' into mainwork 2025-11-02 04:39:42 +09:00
koreacomp5
4d310346c1 main 2025-11-02 04:39:28 +09:00
koreacomp5
cc373f53fe sub 2025-11-02 04:39:23 +09:00
koreacomp5
d057ebef4a 게시판 유형 삭제 2025-11-02 03:12:42 +09:00
koreacomp5
0bf18968ad ㄱㄱ 2025-11-02 02:46:20 +09:00
67 changed files with 1344 additions and 289 deletions

View File

@@ -1,17 +1,18 @@
배너 디테일
카드 디테일
메인 디테일 프리뷰, 글, 스페셜_랭크
기본 리스트 , 글이 없습니다.
글쓰기
글뷰, 댓글 +리스트
메인 게시판 일반
메인 게시판 프리뷰
메인 게시판 스페셜랭크
기본 리스트
스페셜_랭크 스페셜_랭크
스페셜_출석 스페셜_출석
스페셜_제휴업체 스페셜_제휴업체
스페셜_제휴업체지도 스페셜_제휴업체지도
게시글 뷰 + 댓글
로그인관련 로그인관련
회원가입 페이지
회원쪽지 회원쪽지
링크로들어오면 보이고 거기서 페이지이동하면안보이게 링크로들어오면 보이고 거기서 페이지 이동하면 안보이게

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View File

@@ -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: "로그" },
]; ];

View File

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

View File

@@ -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">

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

@@ -5,7 +5,7 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin
const { id } = await context.params; const { id } = await context.params;
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const data: any = {}; const data: any = {};
for (const k of ["name", "slug", "description", "sortOrder", "readLevel", "writeLevel", "allowAnonymousPost", "allowSecretComment", "requiresApproval", "status", "type", "isAdultOnly", "categoryId", "mainPageViewTypeId", "listViewTypeId"]) { for (const k of ["name", "slug", "description", "sortOrder", "readLevel", "writeLevel", "allowAnonymousPost", "allowSecretComment", "status", "isAdultOnly", "categoryId", "mainPageViewTypeId", "listViewTypeId"]) {
if (k in body) data[k] = body[k]; if (k in body) data[k] = body[k];
} }
if ("requiredTags" in body) { if ("requiredTags" in body) {

View File

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

View File

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

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

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

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

View File

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

View File

@@ -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 } },

View File

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

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

@@ -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 (

View File

@@ -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">

View File

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

View File

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

View File

@@ -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>
) : ( ) : (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -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";

View File

@@ -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) => (