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",
"lint": "biome check",
"format": "biome format --write",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"prisma:db:push": "prisma db push",
"prisma:seed": "node prisma/seed.js",
"prisma:erd": "prisma generate"
"migrate": "prisma migrate dev",
"studio": "prisma studio",
"seed": "node prisma/seed.js",
"dbforce": "prisma migrate reset --force"
},
"dependencies": {
"@prisma/client": "^6.17.0",

View File

@@ -0,0 +1,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
}
enum BoardType {
general
special
}
enum AccessLevel {
public // 비회원도 접근
@@ -128,8 +124,6 @@ model Board {
description String?
sortOrder Int @default(0)
status BoardStatus @default(active)
type BoardType @default(general) // 일반/특수
requiresApproval Boolean @default(false) // 게시물 승인 필요 여부
allowAnonymousPost Boolean @default(false) // 익명 글 허용
allowSecretComment Boolean @default(false) // 비밀댓글 허용
isAdultOnly Boolean @default(false) // 성인 인증 필요 여부
@@ -156,7 +150,6 @@ model Board {
updatedAt DateTime @updatedAt
@@index([status, sortOrder])
@@index([type, requiresApproval])
@@index([categoryId])
@@index([mainPageViewTypeId])
@@index([listViewTypeId])
@@ -239,6 +232,10 @@ model User {
birth DateTime
phone String @unique
rank Int @default(0)
// 누적 포인트, 레벨, 등급(0~10)
points Int @default(0)
level Int @default(1)
grade Int @default(0)
status UserStatus @default(active)
authLevel AuthLevel @default(USER)
@@ -675,10 +672,13 @@ model Partner {
latitude Float
longitude Float
address String?
imageUrl String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category])
@@index([sortOrder])
@@map("partners")
}
@@ -742,3 +742,20 @@ model Setting {
@@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();
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() {
// 카테고리 트리 (projectmemo 기준 상위 그룹)
const categories = [
{ name: "암실소문", slug: "main", sortOrder: 1, status: "active" },
{ name: "메인", slug: "main", sortOrder: 1, status: "active" },
{ name: "명예의 전당", slug: "hall-of-fame", sortOrder: 2, status: "active" },
{ name: "주변 제휴업체", slug: "nearby-partners", sortOrder: 3, status: "active" },
{ name: "제휴업소 정보", slug: "partner-info", sortOrder: 4, status: "active" },
{ name: "방문후기", slug: "reviews", sortOrder: 5, status: "active" },
{ name: "소통방", slug: "community", sortOrder: 6, status: "active" },
{ name: "광고/제휴", slug: "ads-affiliates", sortOrder: 7, status: "active" },
{ name: "소통방", slug: "community", sortOrder: 3, status: "active" },
];
const map = {};
for (const c of categories) {
@@ -111,31 +182,28 @@ async function upsertAdmin() {
async function upsertBoards(admin, categoryMap) {
const boards = [
// 일반
{ name: "공지사항", slug: "notice", description: "공지", type: "general", sortOrder: 1, writeLevel: "moderator" },
{ name: "가입인사", slug: "greetings", description: "가입인사", type: "general", sortOrder: 2 },
{ name: "버그건의", slug: "bug-report", description: "버그/건의", type: "general", sortOrder: 3 },
{ name: "이벤트", slug: "event", description: "이벤트", type: "general", sortOrder: 4, requiredTags: { required: ["이벤트"] } },
{ name: "자유게시판", slug: "free", description: "자유", type: "general", sortOrder: 5 },
{ name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", type: "general", sortOrder: 6 },
{ name: "마사지꿀팁", slug: "tips", description: "", type: "general", sortOrder: 7 },
{ name: "익명게시판", slug: "anonymous", description: "익명", type: "general", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true },
{ name: "관리사찾아요", slug: "find-therapist", description: "구인/구직", type: "general", sortOrder: 9 },
{ name: "청와대", slug: "blue-house", description: "레벨 제한", type: "general", sortOrder: 10, readLevel: "member" },
{ name: "방문후기", slug: "reviews", description: "운영자 승인 후 공개", type: "general", sortOrder: 11, requiresApproval: true, requiredTags: { anyOf: ["업체명", "지역"] } },
{ name: "공지사항", slug: "notice", description: "공지", sortOrder: 1, writeLevel: "moderator" },
{ name: "가입인사", slug: "greetings", description: "가입인사", sortOrder: 2 },
{ name: "버그건의", slug: "bug-report", description: "버그/건의", sortOrder: 3 },
{ name: "이벤트", slug: "event", description: "이벤트", sortOrder: 4, requiredTags: { required: ["이벤트"] } },
{ name: "자유게시판", slug: "free", description: "자유", sortOrder: 5 },
{ name: "무엇이든", slug: "qna", description: "무엇이든 물어보세요", sortOrder: 6 },
{ name: "익명게시판", slug: "anonymous", description: "익명", sortOrder: 8, allowAnonymousPost: true, allowSecretComment: true },
{ name: "청와대", slug: "blue-house", description: "레벨 제한", sortOrder: 10, readLevel: "member" },
// 특수
{ name: "출석부", slug: "attendance", description: "데일리 체크인", type: "special", sortOrder: 12 },
{ name: "주변 제휴업체", slug: "nearby-partners", description: "위치 기반", type: "special", sortOrder: 13 },
{ name: "회원랭킹", slug: "ranking", description: "랭킹", type: "special", sortOrder: 14 },
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", type: "special", sortOrder: 15 },
{ name: "월간집계", slug: "monthly-stats", description: "월간 통계", type: "special", sortOrder: 16 },
// 제휴업소 일반
{ name: "제휴업소", slug: "partners-photos", description: "사진 전용 게시판", type: "general", sortOrder: 17, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
// 광고/제휴
{ name: "제휴문의", slug: "partner-contact", description: "제휴문의", type: "general", sortOrder: 18, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
{ name: "제휴업소 요청", slug: "partner-req", description: "제휴업소 요청", type: "general", sortOrder: 19, requiredFields: { imageOnly: true, minImages: 1, maxImages: 10 } },
{ name: "출석부", slug: "attendance", description: "데일리 체크인", sortOrder: 12 },
{ name: "회원랭킹", slug: "ranking", description: "랭킹", sortOrder: 14 },
{ name: "무료쿠폰", slug: "free-coupons", description: "쿠폰", sortOrder: 15 },
{ name: "월간집계", slug: "monthly-stats", description: "월간 통계", sortOrder: 16 },
// 광고/제휴 계열 게시판은 제거(메인/명예의전당/소통방 외)
];
const created = [];
// 특수 랭킹/텍스트 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨)
const 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) {
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
const mapBySlug = {
@@ -149,22 +217,12 @@ async function upsertBoards(admin, categoryMap) {
ranking: "hall-of-fame",
"free-coupons": "hall-of-fame",
"monthly-stats": "hall-of-fame",
// 주변 제휴업체
"nearby-partners": "nearby-partners",
// 제휴업소 정보
"partners-photos": "partner-info",
// 방문후기
reviews: "reviews",
// 소통방(기본값 community로 처리)
free: "community",
qna: "community",
tips: "community",
anonymous: "community",
"find-therapist": "community",
"blue-house": "community",
// 광고/제휴
"partner-contact": "ads-affiliates",
"partner-req": "ads-affiliates",
};
const categorySlug = mapBySlug[b.slug] || "community";
const category = categoryMap[categorySlug];
@@ -173,22 +231,28 @@ async function upsertBoards(admin, categoryMap) {
update: {
description: b.description,
sortOrder: b.sortOrder,
type: b.type,
requiresApproval: !!b.requiresApproval,
allowAnonymousPost: !!b.allowAnonymousPost,
readLevel: b.readLevel || undefined,
categoryId: category ? category.id : undefined,
// 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지
...(b.slug === "ranking"
? (mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {})
: (mainText ? { mainPageViewTypeId: mainText.id } : {})),
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
},
create: {
name: b.name,
slug: b.slug,
description: b.description,
sortOrder: b.sortOrder,
type: b.type,
requiresApproval: !!b.requiresApproval,
allowAnonymousPost: !!b.allowAnonymousPost,
readLevel: b.readLevel || undefined,
categoryId: category ? category.id : undefined,
// 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지
...(b.slug === "ranking"
? (mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {})
: (mainText ? { mainPageViewTypeId: mainText.id } : {})),
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
},
});
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() {
// 금칙어 예시
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() {
await upsertRoles();
const admin = await upsertAdmin();
const categoryMap = await upsertCategories();
await upsertViewTypes();
await createRandomUsers(100);
await removeNonPrimaryBoards();
const boards = await upsertBoards(admin, categoryMap);
// 샘플 글 하나
const free = boards.find((b) => b.slug === "free") || boards[0];
const post = await prisma.post.create({
data: {
boardId: free.id,
authorId: admin.userId,
title: "첫 글",
content: "메시지 앱 초기 설정 완료",
status: "published",
},
});
await prisma.comment.createMany({
data: [
{ postId: post.id, authorId: admin.userId, content: "환영합니다!" },
{ postId: post.id, authorId: admin.userId, content: "댓글 테스트" },
],
});
await seedMainpageVisibleBoards(boards);
await createPostsForAllBoards(boards, 100, admin);
await seedPartnerShops();
await seedBanners();
await seedPolicies();
// 제휴업체 예시 데이터
const partners = [
{ name: "힐링마사지", category: "spa", latitude: 37.5665, longitude: 126.9780, address: "서울 구" },
{ name: "웰빙테라피", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
{ name: "test", category: "spa", latitude: 37.5700, longitude: 126.9769, address: "서울 종로구" },
{ name: "굿짐", category: "gym", latitude: 37.5600, longitude: 126.9820, address: "서울 중구" },
];
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/boards", label: "게시판" },
{ href: "/admin/banners", label: "배너" },
{ href: "/admin/partners", label: "제휴업체" },
{ href: "/admin/users", label: "사용자" },
{ href: "/admin/logs", label: "로그" },
];

View File

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

View File

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

View File

@@ -9,8 +9,11 @@ export async function GET() {
const createSchema = z.object({
title: z.string().min(1),
imageUrl: z.string().url(),
linkUrl: z.string().url().optional(),
// 절대 URL 또는 /로 시작하는 상대경로 허용
imageUrl: z.string().refine((v) => /^https?:\/\//.test(v) || v.startsWith("/"), {
message: "imageUrl must be absolute URL or start with /",
}),
linkUrl: z.string().refine((v) => !v || /^https?:\/\//.test(v), { message: "linkUrl must be http(s) URL" }).optional(),
active: z.boolean().optional(),
sortOrder: z.coerce.number().int().optional(),
startAt: z.coerce.date().optional(),

View File

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

View File

@@ -16,8 +16,6 @@ export async function GET() {
writeLevel: true,
allowAnonymousPost: true,
allowSecretComment: true,
requiresApproval: true,
type: true,
status: true,
categoryId: true,
mainPageViewTypeId: true,
@@ -37,9 +35,7 @@ const createSchema = z.object({
writeLevel: z.string().optional(),
allowAnonymousPost: z.boolean().optional(),
allowSecretComment: z.boolean().optional(),
requiresApproval: z.boolean().optional(),
status: z.string().optional(),
type: z.string().optional(),
isAdultOnly: z.boolean().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,8 +4,9 @@ import prisma from "@/lib/prisma";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const q = searchParams.get("q") || "";
const users = await prisma.user.findMany({
where: q
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get("pageSize") || "20", 10)));
const where = q
? {
OR: [
{ nickname: { contains: q } },
@@ -13,8 +14,15 @@ export async function GET(req: Request) {
{ name: { contains: q } },
],
}
: {},
: {};
const [total, users] = await Promise.all([
prisma.user.count({ where }),
prisma.user.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
userId: true,
nickname: true,
@@ -23,15 +31,19 @@ export async function GET(req: Request) {
status: true,
authLevel: true,
createdAt: true,
points: true,
level: true,
grade: true,
userRoles: { select: { role: { select: { name: true } } } },
},
take: 100,
});
}),
]);
const items = users.map((u) => ({
...u,
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,
slug: true,
description: true,
type: true,
requiresApproval: true,
allowAnonymousPost: true,
isAdultOnly: true,
category: { select: { id: true, name: true, slug: true } },

View File

@@ -10,7 +10,7 @@ export async function GET() {
boards: {
where: { status: "active" },
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 board = await prisma.board.findUnique({ where: { id: boardId } });
const requiresApproval = board?.requiresApproval ?? false;
// 사진형 보드 필수 이미지 검증: content 내 이미지 링크 최소 1개
const isImageOnly = (board?.requiredFields as any)?.imageOnly;
const minImages = (board?.requiredFields as any)?.minImages ?? 0;
@@ -35,7 +34,7 @@ export async function POST(req: Request) {
title,
content,
isAnonymous: !!isAnonymous,
status: requiresApproval ? "hidden" : "published",
status: "published",
},
});
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 { BoardToolbar } from "@/app/components/BoardToolbar";
import { headers } from "next/headers";
import prisma from "@/lib/prisma";
// Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다.
export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) {
const p = params?.then ? await params : params;
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";
// 보드 slug 조회 (새 글 페이지 프리셋 전달)
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 res = await fetch(new URL("/api/boards", base).toString(), { cache: "no-store" });
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 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 (
<div className="space-y-6">
{/* 상단 배너 (서브카테고리 표시) */}
<section>
<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}
/>
</section>
{/* 검색/필터 툴바 + 리스트 */}
<section>
<BoardToolbar boardId={id} />
{!isSpecialRanking && <BoardToolbar boardId={board?.slug} />}
<div className="p-0">
{isSpecialRanking ? (
<div className="rounded-xl border border-neutral-200 overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 flex items-center justify-between bg-[#f6f4f4]">
<h2 className="text-sm text-neutral-700"> </h2>
</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>
</section>
</div>

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
import { headers } from "next/headers";
export default async function BoardsPage() {
@@ -14,7 +15,7 @@ export default async function BoardsPage() {
<h1></h1>
<ul style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{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>
</div>

View File

@@ -35,7 +35,7 @@ export function AppHeader() {
}, [pathname]);
const activeCategorySlug = React.useMemo(() => {
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;
}
if (pathname === "/boards") {
@@ -337,7 +337,7 @@ export function AppHeader() {
style={idx === categories.length - 1 ? { minWidth: 120 } : undefined}
>
<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 ${
activeCategorySlug === cat.slug ? "text-neutral-900" : "text-neutral-700"
}`}
@@ -381,11 +381,11 @@ export function AppHeader() {
{cat.boards.map((b) => (
<Link
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 ${
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}
</Link>
@@ -416,7 +416,7 @@ export function AppHeader() {
<div className="mb-2 font-semibold text-neutral-800">{cat.name}</div>
<div className="flex flex-col gap-1">
{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}
</Link>
))}

View File

@@ -1,9 +1,10 @@
import Link from "next/link";
export function AppSidebar() {
return (
<aside style={{ width: 200, borderRight: "1px solid #eee", padding: 12 }}>
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<li><a href="/boards"></a></li>
<li><a href="/admin"></a></li>
<li><Link href="/boards"></Link></li>
<li><Link href="/admin"></Link></li>
</ul>
</aside>
);

View File

@@ -13,7 +13,7 @@ export function BoardToolbar({ boardId }: { boardId: string }) {
const onChangeSort = (e: React.ChangeEvent<HTMLSelectElement>) => {
const next = new URLSearchParams(sp.toString());
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>) => {
@@ -26,7 +26,7 @@ export function BoardToolbar({ boardId }: { boardId: string }) {
if (v === "1w") now.setDate(now.getDate() - 7);
if (v === "1m") now.setMonth(now.getMonth() - 1);
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) => {
@@ -41,7 +41,7 @@ export function BoardToolbar({ boardId }: { boardId: string }) {
next.delete("author");
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 (

View File

@@ -7,7 +7,7 @@ type ApiCategory = {
id: string;
name: string;
slug: string;
boards: { id: string; name: string; slug: string; type: string; requiresApproval: boolean }[];
boards: { id: string; name: string; slug: string }[];
};
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"
onClick={() => {
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()}
>
@@ -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"
onClick={() => {
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">

View File

@@ -2,12 +2,22 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
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 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 }) {
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 [isHovered, setIsHovered] = useState(false);
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 startedAtRef = useRef<number>(0);
useEffect(() => {
fetch("/api/banners").then((r) => r.json()).then((d) => setBanners(d.banners ?? []));
}, []);
const numSlides = banners.length;
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]);
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 (
<section
@@ -100,7 +132,7 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
{/* Pagination - Figma 스타일: 활성은 주황 바, 비활성은 회색 점 (배너 위에 오버랩) */}
{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) => (
<button
key={i}
@@ -109,8 +141,8 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
onClick={() => goTo(i)}
className={
activeIndex === i
? "h-[4px] w-[18px] rounded-full bg-[#F94B37]"
: "h-[6px] w-[6px] rounded-full bg-[rgba(255,255,255,0.6)] hover:bg-white"
? "h-[6px] w-[24px] rounded-full bg-[#F94B37]"
: "h-[10px] w-[10px] rounded-full bg-[rgba(255,255,255,0.7)] hover:bg-white"
}
/>
))}
@@ -121,9 +153,9 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
{/* 분리된 하단 블랙 바: 높이 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 items-center gap-[8px] overflow-x-auto no-scrollbar">
<div className="flex flex-wrap items-center gap-[8px]">
{subItems.map((s) => (
<a
<Link
key={s.id}
href={s.href}
className={
@@ -133,7 +165,7 @@ export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; ac
}
>
{s.name}
</a>
</Link>
))}
</div>
)}

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
"use client";
import useSWR from "swr";
@@ -16,7 +17,7 @@ export function PersonalWidgets() {
<ul style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{(recent?.items ?? []).map((i) => (
<li key={i.id}>
<a href={`/posts/${i.id}`}>{i.title}</a>
<Link href={`/posts/${i.id}`}>{i.title}</Link>
</li>
))}
{(!recent || recent.items.length === 0) && <li> .</li>}

View File

@@ -1,8 +1,9 @@
"use client";
import useSWRInfinite from "swr/infinite";
import useSWR from "swr";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import ViewsIcon from "@/app/svgs/ViewsIcon";
import LikeIcon from "@/app/svgs/LikeIcon";
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 }) {
const pageSize = 10;
const router = useRouter();
const sp = useSearchParams();
const listContainerRef = useRef<HTMLDivElement | null>(null);
const [lockedMinHeight, setLockedMinHeight] = useState<number | null>(null);
const getKey = (index: number, prev: Resp | 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 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) : "익");
return (
@@ -121,6 +138,7 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
)}
{/* 아이템들 */}
<div ref={listContainerRef} style={{ minHeight: lockedMinHeight ? `${lockedMinHeight}px` : undefined }}>
<ul className="divide-y divide-[#ececec]">
{items.map((p) => (
<li key={p.id} className={`px-4 ${variant === "board" ? "py-2.5" : "py-3 md:py-3"} hover:bg-neutral-50 transition-colors`}>
@@ -129,14 +147,14 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
<div className="hidden md:flex items-center justify-center text-[#f94b37]">{p.isPinned ? "★" : "•"}</div>
<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.title}
</a>
</Link>
{!!p.postTags?.length && (
<div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500">
{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>
)}
@@ -155,6 +173,7 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
</li>
))}
</ul>
</div>
{/* 페이지네이션 */}
{!isEmpty && (
@@ -164,11 +183,14 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
{/* Previous */}
<button
onClick={() => {
lockHeight();
const next = Math.max(1, page - 1);
setPage(next);
const nextSp = new URLSearchParams(Array.from(sp.entries()));
nextSp.set("page", String(next));
router.push(`?${nextSp.toString()}`);
if (typeof window !== "undefined") {
window.history.replaceState(null, "", `?${nextSp.toString()}`);
}
}}
disabled={page <= 1}
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
key={`p-${n}-${idx}`}
onClick={() => {
lockHeight();
setPage(n);
const nextSp = new URLSearchParams(Array.from(sp.entries()));
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}
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 */}
<button
onClick={() => {
lockHeight();
const next = Math.min(totalPages, page + 1);
setPage(next);
const nextSp = new URLSearchParams(Array.from(sp.entries()));
nextSp.set("page", String(next));
router.push(`?${nextSp.toString()}`);
if (typeof window !== "undefined") {
window.history.replaceState(null, "", `?${nextSp.toString()}`);
}
}}
disabled={page >= totalPages}
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>
</div>
{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>
</a>
</Link>
)}
</div>
) : (

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
"use client";
import { usePermission } from "@/lib/usePermission";
@@ -7,8 +8,8 @@ export function QuickActions() {
const isAdmin = can("ADMIN", "ADMINISTER") || can("BOARD", "MODERATE");
return (
<div style={{ display: "flex", gap: 8 }}>
{canWrite && <a href="/posts/new"><button></button></a>}
{isAdmin && <a href="/admin"><button></button></a>}
{canWrite && <Link href="/posts/new"><button></button></Link>}
{isAdmin && <Link href="/admin"><button></button></Link>}
</div>
);
}

View File

@@ -2,24 +2,19 @@
import React from "react";
type Props = {
height?: number | string; // ex) 224 or '14rem'
height?: number | string; // ex) 224 or '14rem' (지정 시 고정 높이, 미지정 시 클래스 높이 사용)
className?: string;
};
// Figma 선택 요소(Container_Event)를 그대로 옮긴 플레이스홀더형 배너
export function SelectedBanner({ height = 122, className }: Props) {
export function SelectedBanner({ height, className }: Props) {
return (
<section
className={`relative w-full overflow-hidden rounded-[12px] bg-[#D9D9D9] ${className ?? ""}`}
style={{ height }}
style={height != null ? { height } : undefined}
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>
);
}

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
"use client";
import useSWR from "swr";
@@ -8,11 +9,11 @@ export function TagFilter({ basePath, current }: { basePath: string; current?: s
const tags = data?.tags ?? [];
return (
<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) => (
<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}
</a>
</Link>
))}
</div>
);

View File

@@ -11,10 +11,11 @@ export function Modal({ open, onClose, children }: Props) {
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
background: "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 50,
}}
>
<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;
}
/* 레이아웃 점프 방지: 스크롤바 여백을 항상 확보 */
html { scrollbar-gutter: stable both-edges; }
/* 유틸: 카드 스켈레톤 색상 헬퍼 (타깃 사이트 톤 유사) */
.bg-neutral-100 { background-color: #f5f5f7; }

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
"use client";
import React from "react";
import { Button } from "@/app/components/ui/Button";
@@ -46,7 +47,7 @@ export default function LoginPage() {
/>
<Button type="submit" disabled={loading}>{loading ? "로그인 중..." : "로그인"}</Button>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<a href="/register"></a>
<Link href="/register"></Link>
</div>
</form>
</div>

View File

@@ -1,4 +1,5 @@
import { HeroBanner } from "@/app/components/HeroBanner";
import Link from "next/link";
import HorizontalCardScroller from "@/app/components/HorizontalCardScroller";
import { PostList } from "@/app/components/PostList";
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 visibleBoardIds: string[] = Array.isArray(parsed.visibleBoardIds) ? parsed.visibleBoardIds : [];
// 보드 메타데이터 (이름 표시용)
// 보드 메타데이터 (메인뷰 타입 포함)
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
.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 restBoards = orderedBoards.slice(2);
const renderBoardPanel = (board: { id: string; name: string }) => (
<div key={board.id} className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col bg-white">
function formatDateYmd(d: Date) {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
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">
<a href={`/boards/${board.id}`} className="text-lg md:text-xl font-bold text-neutral-800 truncate">{board.name}</a>
<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>
<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>
);
};
return (
<div className="space-y-8">
@@ -49,26 +168,34 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
</section>
)}
{/* 제휴 샾 가로 스크롤 (설정 온오프) */}
{showPartnerShops && (() => {
const items = [
{ id: 1, region: "경기도", name: "라온마사지샾", address: "수원시 팔달구 매산로 45", image: "/sample.jpg" },
{ id: 2, region: "강원도", name: "휴앤힐링마사지샾", address: "춘천시 중앙로 112", image: "/sample.jpg" },
{ id: 3, region: "충청북도", name: "소담마사지샾", address: "청주시 상당구 상당로 88", image: "/sample.jpg" },
{ id: 4, region: "충청남도", name: "아늑마사지샾", address: "천안시 동남구 시민로 21", image: "/sample.jpg" },
{ id: 5, region: "전라북도", name: "편안한마사지샾", address: "전주시 완산구 풍남문로 77", image: "/sample.jpg" },
{ id: 6, region: "전라남도", name: "바른마사지샾", address: "여수시 중앙로 9", image: "/sample.jpg" },
{ id: 7, region: "경상북도", name: "늘봄마사지샾", address: "대구시 중구 동성로3길 12", image: "/sample.jpg" },
{ id: 8, region: "경상남도", name: "편히쉬다마사지샾", address: "창원시 성산구 중앙대로 150", image: "/sample.jpg" },
{ id: 9, region: "제주특별자치도", name: "제주소풍마사지샾", address: "제주시 중앙로 230", image: "/sample.jpg" },
{ id: 10, region: "서울특별시", name: "도심휴식마사지샾", address: "강남구 테헤란로 427", image: "/sample.jpg" },
];
return <HorizontalCardScroller items={items} />;
{/* 제휴 샾 가로 스크롤 (설정 온오프, DB에서 불러오기)
- 우선 partners 테이블(관리자 페이지 관리 대상) 사용
- 없으면 partner_shops로 대체 */}
{showPartnerShops && (async () => {
// 우선순위: partners(관리자 관리) → partner_shops(폴백)
let partners: any[] = [];
try {
partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }], take: 10 });
} catch (_) {
partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" }, take: 10 });
}
const items = partners.map((p: any) => ({
id: p.id,
region: p.address ? String(p.address).split(" ")[0] : p.category,
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개) */}
{(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="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" />
@@ -135,9 +262,9 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
</button>
</div>
</div>
{firstTwo.map((b) => (
<div key={b.id} className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
{renderBoardPanel(b)}
{(await Promise.all(firstTwo.map((b) => renderBoardPanel(b)))).map((panel, idx) => (
<div key={firstTwo[idx].id} className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
{panel}
</div>
))}
</div>
@@ -147,14 +274,14 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
{/* 나머지 보드: 2개씩 다음 열로 렌더링 */}
{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);
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">
{pair.map((b) => (
<div key={b.id} className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
{renderBoardPanel(b)}
{(await Promise.all(pair.map((b) => renderBoardPanel(b)))).map((panel, idx) => (
<div key={pair[idx].id} className="rounded-xl overflow-hidden h-full min-h-0 flex flex-col">
{panel}
</div>
))}
</div>

View File

@@ -8,7 +8,7 @@ import { HeroBanner } from "@/app/components/HeroBanner";
export default function EditPostPage() {
const params = useParams<{ id: string }>();
const id = params?.id as string;
const id = params?.id as string; // slug 값
const router = useRouter();
const { show } = useToast();
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합니다.
export default async function PostDetail({ params }: { params: any }) {
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 host = h.get("host") ?? "localhost:3000";
const proto = h.get("x-forwarded-proto") ?? "http";

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
"use client";
import useSWR from "swr";
@@ -10,10 +11,10 @@ export default function RankingPage({ searchParams }: { searchParams?: { period?
<div>
<h1></h1>
<div style={{ display: "flex", gap: 8, margin: "8px 0" }}>
<a href={`/ranking?period=daily`}></a>
<a href={`/ranking?period=weekly`}></a>
<a href={`/ranking?period=monthly`}></a>
<a href={`/ranking?period=all`}></a>
<Link href={`/ranking?period=daily`}></Link>
<Link href={`/ranking?period=weekly`}></Link>
<Link href={`/ranking?period=monthly`}></Link>
<Link href={`/ranking?period=all`}></Link>
</div>
<ol>
{(data?.items ?? []).map((i) => (