diff --git a/.cursor/.prompt/1101.md b/.cursor/.prompt/1101.md index f37a43d..f676852 100644 --- a/.cursor/.prompt/1101.md +++ b/.cursor/.prompt/1101.md @@ -1,17 +1,18 @@ -배너 디테일 -카드 디테일 -메인 디테일 프리뷰, 글, 스페셜_랭크 -기본 리스트 , 글이 없습니다. -글쓰기 -글뷰, 댓글 +리스트 +메인 게시판 일반 +메인 게시판 프리뷰 +메인 게시판 스페셜랭크 + +기본 리스트 스페셜_랭크 스페셜_출석 스페셜_제휴업체 스페셜_제휴업체지도 +게시글 뷰 + 댓글 로그인관련 +회원가입 페이지 회원쪽지 -링크로들어오면 보이고 거기서 페이지이동하면안보이게 \ No newline at end of file +링크로들어오면 보이고 거기서 페이지 이동하면 안보이게 \ No newline at end of file diff --git a/.cursor/.prompt/normalboardwork.md b/.cursor/.prompt/done/normalboardwork.md similarity index 100% rename from .cursor/.prompt/normalboardwork.md rename to .cursor/.prompt/done/normalboardwork.md diff --git a/.cursor/.prompt/profilearea.md b/.cursor/.prompt/done/profilearea.md similarity index 100% rename from .cursor/.prompt/profilearea.md rename to .cursor/.prompt/done/profilearea.md diff --git a/.cursor/.prompt/search.md b/.cursor/.prompt/done/search.md similarity index 100% rename from .cursor/.prompt/search.md rename to .cursor/.prompt/done/search.md diff --git a/.cursor/.prompt/worklist.md b/.cursor/.prompt/done/worklist.md similarity index 100% rename from .cursor/.prompt/worklist.md rename to .cursor/.prompt/done/worklist.md diff --git a/package.json b/package.json index 1a06fd8..9257ccc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20251101152100_/migration.sql b/prisma/migrations/20251101152100_/migration.sql new file mode 100644 index 0000000..a3b957d --- /dev/null +++ b/prisma/migrations/20251101152100_/migration.sql @@ -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"); diff --git a/prisma/migrations/20251101161632_/migration.sql b/prisma/migrations/20251101161632_/migration.sql new file mode 100644 index 0000000..a71df66 --- /dev/null +++ b/prisma/migrations/20251101161632_/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "partners" ADD COLUMN "imageUrl" TEXT; diff --git a/prisma/migrations/20251101162445_/migration.sql b/prisma/migrations/20251101162445_/migration.sql new file mode 100644 index 0000000..0e3ad79 --- /dev/null +++ b/prisma/migrations/20251101162445_/migration.sql @@ -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; diff --git a/prisma/migrations/20251101170000_add_user_points_level_grade/migration.sql b/prisma/migrations/20251101170000_add_user_points_level_grade/migration.sql new file mode 100644 index 0000000..09c0acf --- /dev/null +++ b/prisma/migrations/20251101170000_add_user_points_level_grade/migration.sql @@ -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; + + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 10c9fe0..8fb989c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/prisma/seed.js b/prisma/seed.js index b7cbca3..2143f36 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -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) { diff --git a/public/uploads/1762011428045-b2uxup0646v.png b/public/uploads/1762011428045-b2uxup0646v.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762011428045-b2uxup0646v.png differ diff --git a/public/uploads/1762011528689-r5qbw3daoq.png b/public/uploads/1762011528689-r5qbw3daoq.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762011528689-r5qbw3daoq.png differ diff --git a/public/uploads/1762012666810-zhoeib9y8we.png b/public/uploads/1762012666810-zhoeib9y8we.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762012666810-zhoeib9y8we.png differ diff --git a/public/uploads/1762013315968-jent9fluatl.png b/public/uploads/1762013315968-jent9fluatl.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762013315968-jent9fluatl.png differ diff --git a/public/uploads/1762013865590-izqoqn8qgbm.png b/public/uploads/1762013865590-izqoqn8qgbm.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762013865590-izqoqn8qgbm.png differ diff --git a/public/uploads/1762014531262-2vmcxdk945u.jpg b/public/uploads/1762014531262-2vmcxdk945u.jpg new file mode 100644 index 0000000..44c7728 Binary files /dev/null and b/public/uploads/1762014531262-2vmcxdk945u.jpg differ diff --git a/public/uploads/1762014639578-9e1067twpw.png b/public/uploads/1762014639578-9e1067twpw.png new file mode 100644 index 0000000..2c01eda Binary files /dev/null and b/public/uploads/1762014639578-9e1067twpw.png differ diff --git a/public/uploads/1762014695925-jmes4cxd0vd.png b/public/uploads/1762014695925-jmes4cxd0vd.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762014695925-jmes4cxd0vd.png differ diff --git a/public/uploads/1762014716631-fgq5a179wwr.png b/public/uploads/1762014716631-fgq5a179wwr.png new file mode 100644 index 0000000..44ab9c6 Binary files /dev/null and b/public/uploads/1762014716631-fgq5a179wwr.png differ diff --git a/public/uploads/1762014821297-9qbwphmxm05.png b/public/uploads/1762014821297-9qbwphmxm05.png new file mode 100644 index 0000000..44ab9c6 Binary files /dev/null and b/public/uploads/1762014821297-9qbwphmxm05.png differ diff --git a/public/uploads/1762015671690-p67kkblxdml.png b/public/uploads/1762015671690-p67kkblxdml.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762015671690-p67kkblxdml.png differ diff --git a/public/uploads/1762015830912-1wsv0cfchd8.jpg b/public/uploads/1762015830912-1wsv0cfchd8.jpg new file mode 100644 index 0000000..d718a07 Binary files /dev/null and b/public/uploads/1762015830912-1wsv0cfchd8.jpg differ diff --git a/public/uploads/1762016086149-vcxoon8tg8.png b/public/uploads/1762016086149-vcxoon8tg8.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762016086149-vcxoon8tg8.png differ diff --git a/public/uploads/1762016548518-0d2zhs3f44bq.png b/public/uploads/1762016548518-0d2zhs3f44bq.png new file mode 100644 index 0000000..7db214e Binary files /dev/null and b/public/uploads/1762016548518-0d2zhs3f44bq.png differ diff --git a/public/uploads/1762017507500-08hp85ex35v.png b/public/uploads/1762017507500-08hp85ex35v.png new file mode 100644 index 0000000..789d1ff Binary files /dev/null and b/public/uploads/1762017507500-08hp85ex35v.png differ diff --git a/public/uploads/1762017553592-w9qnbapfb2.png b/public/uploads/1762017553592-w9qnbapfb2.png new file mode 100644 index 0000000..44ab9c6 Binary files /dev/null and b/public/uploads/1762017553592-w9qnbapfb2.png differ diff --git a/public/uploads/1762017624031-rni0unzdl6c.jpg b/public/uploads/1762017624031-rni0unzdl6c.jpg new file mode 100644 index 0000000..6ee9978 Binary files /dev/null and b/public/uploads/1762017624031-rni0unzdl6c.jpg differ diff --git a/public/uploads/1762025382776-83vifeqk7rk.webp b/public/uploads/1762025382776-83vifeqk7rk.webp new file mode 100644 index 0000000..8373104 Binary files /dev/null and b/public/uploads/1762025382776-83vifeqk7rk.webp differ diff --git a/src/app/admin/AdminSidebar.tsx b/src/app/admin/AdminSidebar.tsx index 95208a5..d6e3653 100644 --- a/src/app/admin/AdminSidebar.tsx +++ b/src/app/admin/AdminSidebar.tsx @@ -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: "로그" }, ]; diff --git a/src/app/admin/banners/page.tsx b/src/app/admin/banners/page.tsx index db11f27..76acf63 100644 --- a/src/app/admin/banners/page.tsx +++ b/src/app/admin/banners/page.tsx @@ -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(null); + const [showCreateModal, setShowCreateModal] = useState(false); + async function onSelectFile(e: React.ChangeEvent) { + 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 (
-

배너 관리

-
- setForm({ ...form, title: e.target.value })} /> - setForm({ ...form, imageUrl: e.target.value })} /> - setForm({ ...form, linkUrl: e.target.value })} /> - - setForm({ ...form, sortOrder: Number(e.target.value) })} style={{ width: 80 }} /> - +
+

배너 관리

+
+ setShowCreateModal(false)}> +
+
+

배너 추가

+ {(() => { + const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" }; + return ( +
+
+ + setForm({ ...form, title: e.target.value })} /> +
+
+ + setForm({ ...form, linkUrl: e.target.value })} /> +
+
+ +
+ setForm({ ...form, imageUrl: e.target.value })} /> + + + {uploading && 업로드 중...} +
+
+
+ + +
+
+ + setForm({ ...form, sortOrder: Number(e.target.value) })} /> +
+ {form.imageUrl && ( +
+
미리보기
+ 미리보기 +
+ )} +
+ + +
+
+ ); + })()} +
+
+
    {banners.map((b) => (
  • @@ -31,8 +123,8 @@ export default function AdminBannersPage() {
    {b.title} {b.linkUrl && 링크}
    정렬 {b.sortOrder} · {b.active ? "활성" : "비활성"}
- - + + ))} diff --git a/src/app/admin/boards/page.tsx b/src/app/admin/boards/page.tsx index bf0ac5c..f404a38 100644 --- a/src/app/admin/boards/page.tsx +++ b/src/app/admin/boards/page.tsx @@ -368,8 +368,6 @@ export default function AdminBoardsPage() { 쓰기 익명 비밀댓 - 승인 - 유형 성인 대분류 이동 활성 @@ -502,13 +500,6 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma { const v = { ...edit, allowAnonymousPost: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /> { const v = { ...edit, allowSecretComment: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /> - { const v = { ...edit, requiresApproval: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /> - - - { const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /> {allowMove && categories && onMove ? ( diff --git a/src/app/admin/partner-shops/page.tsx b/src/app/admin/partner-shops/page.tsx new file mode 100644 index 0000000..d2305f7 --- /dev/null +++ b/src/app/admin/partner-shops/page.tsx @@ -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 ( +
+

제휴 샵 관리

+
+ setForm({ ...form, region: e.target.value })} /> + setForm({ ...form, name: e.target.value })} /> + setForm({ ...form, address: e.target.value })} style={{ width: 320 }} /> + setForm({ ...form, imageUrl: e.target.value })} style={{ width: 280 }} /> + + setForm({ ...form, sortOrder: Number(e.target.value) })} style={{ width: 80 }} /> + +
+
    + {items.map((it) => ( +
  • + {it.name} +
    +
    {it.name} {it.region}
    +
    {it.address}
    +
    정렬 {it.sortOrder} · {it.active ? "활성" : "비활성"}
    +
    + + +
  • + ))} +
+
+ ); +} + + diff --git a/src/app/admin/partners/page.tsx b/src/app/admin/partners/page.tsx new file mode 100644 index 0000000..972a5a3 --- /dev/null +++ b/src/app/admin/partners/page.tsx @@ -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(null); + const editFileInputRef = useRef(null); + const [editUploading, setEditUploading] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editDraft, setEditDraft] = useState(null); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + + async function onSelectFile(e: React.ChangeEvent) { + 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) { + 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 ( +
+
+

제휴업체 관리

+ +
+ setShowCreateModal(false)}> +
+
+

제휴업체 추가

+ {(() => { + const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" }; + return ( +
+
+ + setForm({ ...form, name: e.target.value })} /> +
+
+ + setForm({ ...form, category: e.target.value })} /> +
+
+ + setForm({ ...form, latitude: e.target.value })} /> +
+
+ + setForm({ ...form, longitude: e.target.value })} /> +
+
+ +
+ setForm({ ...form, imageUrl: e.target.value })} /> + + + {uploading && 업로드 중...} +
+
+
+ + setForm({ ...form, address: e.target.value })} /> +
+ {form.imageUrl && ( +
+
미리보기
+ 미리보기 +
+ )} +
+ + +
+
+ ); + })()} +
+
+
+ { setShowEditModal(false); setEditingId(null); setEditDraft(null); }}> +
+
+

제휴업체 수정

+ {(() => { + const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" }; + return ( +
+
+ + setEditDraft({ ...editDraft, name: e.target.value })} /> +
+
+ + setEditDraft({ ...editDraft, category: e.target.value })} /> +
+
+ + setEditDraft({ ...editDraft, latitude: e.target.value })} /> +
+
+ + setEditDraft({ ...editDraft, longitude: e.target.value })} /> +
+
+ +
+ setEditDraft({ ...editDraft, imageUrl: e.target.value })} /> + + + {editUploading && 업로드 중...} +
+
+
+ + setEditDraft({ ...editDraft, address: e.target.value })} /> +
+ {editDraft?.imageUrl && ( +
+
미리보기
+ 미리보기 +
+ )} +
+ + +
+
+ ); + })()} +
+
+
+
    + {partners.map((p) => ( +
  • +
    + {p.imageUrl ? ( + {p.name} + ) : ( +
    + )} +
    + {false ? ( + <> + ) : ( + <> +
    +
    {p.name} {p.category}
    +
    {p.address || "(주소 없음)"}
    +
    +
    위도 {p.latitude}
    +
    경도 {p.longitude}
    +
    + + + + +
    + + )} +
  • + ))} +
+
+ ); +} + + diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 0aeafba..2d99278 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -1,36 +1,80 @@ "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 (

사용자 관리

-
- setQ(e.target.value)} /> +
+ setQueryDraft(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }} + /> + + 총 {total.toLocaleString()}명 +
+ +
+ + + + + + + + + + + + + + + + {users.map((u) => ( + + ))} + +
닉네임이름전화포인트레벨등급상태권한작업
+
+ +
+ + 페이지 {page} / {totalPages} + + 페이지 크기 +
- - - - - - - - - - - - - {users.map((u) => ( - - ))} - -
닉네임이름전화상태권한
); } @@ -45,25 +89,30 @@ function Row({ u, onChanged }: { u: any; onChanged: () => void }) { } const allRoles = ["admin", "editor", "user"] as const; return ( - - {u.nickname} - {u.name} - {u.phone} - - setStatus(e.target.value)}> + + + - + {allRoles.map((r) => ( -
diff --git a/src/app/components/AppHeader.tsx b/src/app/components/AppHeader.tsx index 65944f4..845af95 100644 --- a/src/app/components/AppHeader.tsx +++ b/src/app/components/AppHeader.tsx @@ -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} > ( {b.name} @@ -416,7 +416,7 @@ export function AppHeader() {
{cat.name}
{cat.boards.map((b) => ( - setMobileOpen(false)} className="rounded px-2 py-1 text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900"> + setMobileOpen(false)} className="rounded px-2 py-1 text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900"> {b.name} ))} diff --git a/src/app/components/AppSidebar.tsx b/src/app/components/AppSidebar.tsx index e51cb06..ae73a07 100644 --- a/src/app/components/AppSidebar.tsx +++ b/src/app/components/AppSidebar.tsx @@ -1,9 +1,10 @@ +import Link from "next/link"; export function AppSidebar() { return ( ); diff --git a/src/app/components/BoardToolbar.tsx b/src/app/components/BoardToolbar.tsx index 85be335..b0ffc4a 100644 --- a/src/app/components/BoardToolbar.tsx +++ b/src/app/components/BoardToolbar.tsx @@ -13,7 +13,7 @@ export function BoardToolbar({ boardId }: { boardId: string }) { const onChangeSort = (e: React.ChangeEvent) => { 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) => { @@ -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 ( diff --git a/src/app/components/CategoryBoardBrowser.tsx b/src/app/components/CategoryBoardBrowser.tsx index b35dc51..28c85b2 100644 --- a/src/app/components/CategoryBoardBrowser.tsx +++ b/src/app/components/CategoryBoardBrowser.tsx @@ -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}`); }} >
+ {Array.isArray(subItems) && subItems.length > 0 && ( +
+ {subItems.map((s) => ( + + {s.name} + + ))} +
+ )} +
+ + ); + } return (
1 && ( -
+
{banners.map((_, i) => (
{newPostHref && ( - + - + )}
) : ( diff --git a/src/app/components/QuickActions.tsx b/src/app/components/QuickActions.tsx index 5e62e50..787fd52 100644 --- a/src/app/components/QuickActions.tsx +++ b/src/app/components/QuickActions.tsx @@ -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 (
- {canWrite && } - {isAdmin && } + {canWrite && } + {isAdmin && }
); } diff --git a/src/app/components/SelectedBanner.tsx b/src/app/components/SelectedBanner.tsx index 316ca1d..398216c 100644 --- a/src/app/components/SelectedBanner.tsx +++ b/src/app/components/SelectedBanner.tsx @@ -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 (
-
- - {Array.from({ length: 12 }).map((_, i) => ( - - ))} -
+ {/* 스켈레톤 상태에서는 하단 점(페이지네이션) 제거 */}
); } diff --git a/src/app/components/TagFilter.tsx b/src/app/components/TagFilter.tsx index eb30ee7..3cbf3d1 100644 --- a/src/app/components/TagFilter.tsx +++ b/src/app/components/TagFilter.tsx @@ -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 (
- 전체 + 전체 {tags.map((t) => ( - + #{t.name} - + ))}
); diff --git a/src/app/components/ui/Modal.tsx b/src/app/components/ui/Modal.tsx index d501a91..24fb347 100644 --- a/src/app/components/ui/Modal.tsx +++ b/src/app/components/ui/Modal.tsx @@ -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, }} >
e.stopPropagation()} style={{ background: "#fff", padding: 16, borderRadius: 8 }}> diff --git a/src/app/globals.css b/src/app/globals.css index 64cd572..8e99d55 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 7d81f7e..bb4794f 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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() { />
- 회원가입 + 회원가입
diff --git a/src/app/page.tsx b/src/app/page.tsx index 6b765bb..45b7b4a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 }) => ( -
-
- {board.name} - 더보기 + 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 ( +
+
+
+
{board.categoryName || board.name}
+
+
+ {siblingBoards.map((sb) => ( + + {sb.name} + + ))} +
+
+
+
+ {board.name} + 더보기 +
+
+ +
+
+
+ ); + } + + 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 ( +
+
+
+
{board.categoryName || board.name}
+
+
+ {siblingBoards.map((sb) => ( + + {sb.name} + + ))} +
+
+
+
+ {board.name} + 더보기 +
+
+
+
    + {posts.map((p) => ( +
  • +
    +
    +
    +
    +
    n
    +
    + {p.title} + +{p.stat?.recommendCount ?? 0} +
    + {formatDateYmd(new Date(p.createdAt))} +
    +
  • + ))} +
+
+
+
-
- -
-
- ); + ); + }; return (
@@ -49,26 +168,34 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
)} - {/* 제휴 샾 가로 스크롤 (설정 온오프) */} - {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 ; + {/* 제휴 샾 가로 스크롤 (설정 온오프, 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 ; + + 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 ; })()} {/* 1행: 프로필 + 선택된 보드 2개 (최대 2개) */} {(firstTwo.length > 0) && ( -
+
@@ -135,9 +262,9 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
- {firstTwo.map((b) => ( -
- {renderBoardPanel(b)} + {(await Promise.all(firstTwo.map((b) => renderBoardPanel(b)))).map((panel, idx) => ( +
+ {panel}
))}
@@ -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 ( -
+
- {pair.map((b) => ( -
- {renderBoardPanel(b)} + {(await Promise.all(pair.map((b) => renderBoardPanel(b)))).map((panel, idx) => ( +
+ {panel}
))}
diff --git a/src/app/posts/[id]/edit/page.tsx b/src/app/posts/[id]/edit/page.tsx index 131af37..e018b3e 100644 --- a/src/app/posts/[id]/edit/page.tsx +++ b/src/app/posts/[id]/edit/page.tsx @@ -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); diff --git a/src/app/posts/[id]/page.tsx b/src/app/posts/[id]/page.tsx index c4339ce..bfea4e0 100644 --- a/src/app/posts/[id]/page.tsx +++ b/src/app/posts/[id]/page.tsx @@ -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"; diff --git a/src/app/ranking/page.tsx b/src/app/ranking/page.tsx index cb3be54..9477401 100644 --- a/src/app/ranking/page.tsx +++ b/src/app/ranking/page.tsx @@ -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?

회원랭킹

- 일간 - 주간 - 월간 - 전체 + 일간 + 주간 + 월간 + 전체
    {(data?.items ?? []).map((i) => (