ㄱㄱ
10
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",
|
||||
|
||||
18
prisma/migrations/20251101152100_/migration.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "partner_shops" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"region" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"address" TEXT NOT NULL,
|
||||
"imageUrl" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "partner_shops_active_sortOrder_idx" ON "partner_shops"("active", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "partner_shops_name_region_key" ON "partner_shops"("name", "region");
|
||||
2
prisma/migrations/20251101161632_/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "partners" ADD COLUMN "imageUrl" TEXT;
|
||||
23
prisma/migrations/20251101162445_/migration.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_partners" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"latitude" REAL NOT NULL,
|
||||
"longitude" REAL NOT NULL,
|
||||
"address" TEXT,
|
||||
"imageUrl" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_partners" ("address", "category", "createdAt", "id", "imageUrl", "latitude", "longitude", "name", "updatedAt") SELECT "address", "category", "createdAt", "id", "imageUrl", "latitude", "longitude", "name", "updatedAt" FROM "partners";
|
||||
DROP TABLE "partners";
|
||||
ALTER TABLE "new_partners" RENAME TO "partners";
|
||||
CREATE UNIQUE INDEX "partners_name_key" ON "partners"("name");
|
||||
CREATE INDEX "partners_category_idx" ON "partners"("category");
|
||||
CREATE INDEX "partners_sortOrder_idx" ON "partners"("sortOrder");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -675,10 +675,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 +745,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")
|
||||
}
|
||||
|
||||
180
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) {
|
||||
@@ -119,20 +190,13 @@ async function upsertBoards(admin, categoryMap) {
|
||||
{ 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: "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 } },
|
||||
// 광고/제휴 계열 게시판은 제거(메인/명예의전당/소통방 외)
|
||||
];
|
||||
|
||||
const created = [];
|
||||
@@ -149,22 +213,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];
|
||||
@@ -229,6 +283,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 +342,46 @@ 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 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 createPostsForAllBoards(boards, 100, admin);
|
||||
await seedPartnerShops();
|
||||
|
||||
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) {
|
||||
|
||||
BIN
public/uploads/1762011428045-b2uxup0646v.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762011528689-r5qbw3daoq.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762012666810-zhoeib9y8we.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762013315968-jent9fluatl.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762013865590-izqoqn8qgbm.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762014531262-2vmcxdk945u.jpg
Normal file
|
After Width: | Height: | Size: 411 KiB |
BIN
public/uploads/1762014639578-9e1067twpw.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/uploads/1762014695925-jmes4cxd0vd.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762014716631-fgq5a179wwr.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762014821297-9qbwphmxm05.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762015671690-p67kkblxdml.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762015830912-1wsv0cfchd8.jpg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
public/uploads/1762016086149-vcxoon8tg8.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762016548518-0d2zhs3f44bq.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/uploads/1762017507500-08hp85ex35v.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762017553592-w9qnbapfb2.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/uploads/1762017624031-rni0unzdl6c.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
@@ -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: "로그" },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
45
src/app/admin/partner-shops/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
import useSWR from "swr";
|
||||
import { useState } from "react";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export default function AdminPartnerShopsPage() {
|
||||
const { data, mutate } = useSWR<{ items: any[] }>("/api/admin/partner-shops", fetcher);
|
||||
const items = data?.items ?? [];
|
||||
const [form, setForm] = useState({ region: "", name: "", address: "", imageUrl: "", active: true, sortOrder: 0 });
|
||||
async function create() {
|
||||
const r = await fetch("/api/admin/partner-shops", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(form) });
|
||||
if (r.ok) { setForm({ region: "", name: "", address: "", imageUrl: "", active: true, sortOrder: 0 }); mutate(); }
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<h1>제휴 샵 관리</h1>
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 12, flexWrap: "wrap" }}>
|
||||
<input placeholder="지역" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} />
|
||||
<input placeholder="이름" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
<input placeholder="주소" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} style={{ width: 320 }} />
|
||||
<input placeholder="이미지 URL" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} style={{ width: 280 }} />
|
||||
<label><input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} /> 활성</label>
|
||||
<input type="number" placeholder="정렬" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} style={{ width: 80 }} />
|
||||
<button onClick={create}>추가</button>
|
||||
</div>
|
||||
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{items.map((it) => (
|
||||
<li key={it.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<img src={it.imageUrl} alt={it.name} style={{ width: 80, height: 48, objectFit: "cover", borderRadius: 6 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div><strong>{it.name}</strong> <span style={{ marginLeft: 8, fontSize: 12, opacity: .7 }}>{it.region}</span></div>
|
||||
<div style={{ fontSize: 12, opacity: 0.7 }}>{it.address}</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.7 }}>정렬 {it.sortOrder} · {it.active ? "활성" : "비활성"}</div>
|
||||
</div>
|
||||
<button onClick={async () => { await fetch(`/api/admin/partner-shops/${it.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ active: !it.active }) }); mutate(); }}>{it.active ? "비활성" : "활성"}</button>
|
||||
<button onClick={async () => { await fetch(`/api/admin/partner-shops/${it.id}`, { method: "DELETE" }); mutate(); }}>삭제</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
295
src/app/admin/partners/page.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
import useSWR from "swr";
|
||||
import { useState, useRef } from "react";
|
||||
import { Modal } from "@/app/components/ui/Modal";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export default function AdminPartnersPage() {
|
||||
const { data, mutate } = useSWR<{ partners: any[] }>("/api/admin/partners", fetcher);
|
||||
const partners = data?.partners ?? [];
|
||||
const [form, setForm] = useState({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const editFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [editUploading, setEditUploading] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editDraft, setEditDraft] = useState<any>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
async function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const inputEl = e.currentTarget;
|
||||
const file = inputEl.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
setUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const r = await fetch("/api/uploads", { method: "POST", body: fd });
|
||||
const json = await r.json();
|
||||
if (!r.ok) throw new Error(json?.error || "upload_failed");
|
||||
setForm((f) => ({ ...f, imageUrl: json.url }));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("이미지 업로드 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputEl) inputEl.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelectEditFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const inputEl = e.currentTarget;
|
||||
const file = inputEl.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
setEditUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const r = await fetch("/api/uploads", { method: "POST", body: fd });
|
||||
const json = await r.json();
|
||||
if (!r.ok) throw new Error(json?.error || "upload_failed");
|
||||
setEditDraft((d: any) => ({ ...(d || {}), imageUrl: json.url }));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("이미지 업로드 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setEditUploading(false);
|
||||
if (inputEl) inputEl.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
// 필수값 검증: 이름/카테고리/위도/경도
|
||||
if (!form.name || !form.category || !String(form.latitude).trim() || !String(form.longitude).trim()) {
|
||||
alert("이름, 카테고리, 위도, 경도를 모두 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
const lat = Number(form.latitude);
|
||||
const lon = Number(form.longitude);
|
||||
if (!isFinite(lat) || !isFinite(lon)) {
|
||||
alert("위도/경도는 숫자여야 합니다.");
|
||||
return;
|
||||
}
|
||||
const payload = { ...form, latitude: lat, longitude: lon } as any;
|
||||
const r = await fetch("/api/admin/partners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
||||
if (r.ok) {
|
||||
setForm({ name: "", category: "", latitude: "", longitude: "", address: "", imageUrl: "" });
|
||||
mutate();
|
||||
setShowCreateModal(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
|
||||
<h1>제휴업체 관리</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
<Modal open={showCreateModal} onClose={() => setShowCreateModal(false)}>
|
||||
<div className="w-[720px] max-w-[90vw]">
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-bold mb-4">제휴업체 추가</h2>
|
||||
{(() => {
|
||||
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
|
||||
<div style={{ gridColumn: "span 3" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>이름</label>
|
||||
<input style={inputStyle} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 3" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>카테고리</label>
|
||||
<input style={inputStyle} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 3" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
||||
<input style={inputStyle} value={form.latitude} onChange={(e) => setForm({ ...form, latitude: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 3" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>경도</label>
|
||||
<input style={inputStyle} value={form.longitude} onChange={(e) => setForm({ ...form, longitude: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 6" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>이미지 URL</label>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<input style={inputStyle} value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
|
||||
>
|
||||
업로드
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={onSelectFile} style={{ display: "none" }} />
|
||||
{uploading && <span style={{ fontSize: 12, opacity: .7 }}>업로드 중...</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 6" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>주소(선택)</label>
|
||||
<input style={inputStyle} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||||
</div>
|
||||
{form.imageUrl && (
|
||||
<div style={{ gridColumn: "span 6" }}>
|
||||
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}>미리보기</div>
|
||||
<img src={form.imageUrl} alt="미리보기" style={{ width: 240, height: 120, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||
<button
|
||||
onClick={create}
|
||||
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal open={showEditModal} onClose={() => { setShowEditModal(false); setEditingId(null); setEditDraft(null); }}>
|
||||
<div className="w-[720px] max-w-[90vw]">
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-bold mb-4">제휴업체 수정</h2>
|
||||
{(() => {
|
||||
const inputStyle: React.CSSProperties = { border: "1px solid #ddd", borderRadius: 6, padding: "8px 10px", width: "100%" };
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, minmax(0, 1fr))", gap: 12, alignItems: "end" }}>
|
||||
<div style={{ gridColumn: "span 3" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>이름</label>
|
||||
<input style={inputStyle} value={editDraft?.name ?? ""} onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 3" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>카테고리</label>
|
||||
<input style={inputStyle} value={editDraft?.category ?? ""} onChange={(e) => setEditDraft({ ...editDraft, category: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 3" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>위도</label>
|
||||
<input style={inputStyle} value={editDraft?.latitude ?? ""} onChange={(e) => setEditDraft({ ...editDraft, latitude: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 3" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>경도</label>
|
||||
<input style={inputStyle} value={editDraft?.longitude ?? ""} onChange={(e) => setEditDraft({ ...editDraft, longitude: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 6" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>이미지 URL</label>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<input style={inputStyle} value={editDraft?.imageUrl ?? ""} onChange={(e) => setEditDraft({ ...editDraft, imageUrl: e.target.value })} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editFileInputRef.current?.click()}
|
||||
disabled={editUploading}
|
||||
className="h-9 px-4 w-36 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 hover:border-neutral-400 active:translate-y-px transition"
|
||||
>
|
||||
업로드
|
||||
</button>
|
||||
<input ref={editFileInputRef} type="file" accept="image/*" onChange={onSelectEditFile} style={{ display: "none" }} />
|
||||
{editUploading && <span style={{ fontSize: 12, opacity: .7 }}>업로드 중...</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 6" }}>
|
||||
<label style={{ display: "block", fontSize: 12, fontWeight: 600, marginBottom: 6 }}>주소</label>
|
||||
<input style={inputStyle} value={editDraft?.address ?? ""} onChange={(e) => setEditDraft({ ...editDraft, address: e.target.value })} />
|
||||
</div>
|
||||
{editDraft?.imageUrl && (
|
||||
<div style={{ gridColumn: "span 6" }}>
|
||||
<div style={{ fontSize: 12, opacity: .7, marginBottom: 6 }}>미리보기</div>
|
||||
<img src={editDraft.imageUrl} alt="미리보기" style={{ width: 240, height: 120, objectFit: "cover", borderRadius: 6, border: "1px solid #eee" }} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ gridColumn: "span 6", display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||
<button
|
||||
onClick={async () => { if (!editingId) return; await fetch(`/api/admin/partners/${editingId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...editDraft }) }); setEditingId(null); setEditDraft(null); setShowEditModal(false); mutate(); }}
|
||||
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm shadow cursor-pointer hover:bg-neutral-800 active:translate-y-px transition"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowEditModal(false); setEditingId(null); setEditDraft(null); }}
|
||||
className="h-9 px-4 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<ul style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{partners.map((p) => (
|
||||
<li key={p.id} style={{ border: "1px solid #eee", borderRadius: 8, padding: 12, display: "grid", gridTemplateColumns: "auto 1fr 1fr 1fr auto", gap: 8, alignItems: "center" }}>
|
||||
<div style={{ width: 80 }}>
|
||||
{p.imageUrl ? (
|
||||
<img src={p.imageUrl} alt={p.name} style={{ width: 80, height: 48, objectFit: "cover", borderRadius: 6 }} />
|
||||
) : (
|
||||
<div style={{ width: 80, height: 48, border: "1px solid #eee", borderRadius: 6, background: "#fafafa" }} />
|
||||
)}
|
||||
</div>
|
||||
{false ? (
|
||||
<></>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<div><strong>{p.name}</strong> <span style={{ marginLeft: 6, fontSize: 12, opacity: .7 }}>{p.category}</span></div>
|
||||
<div style={{ fontSize: 12, opacity: .7 }}>{p.address || "(주소 없음)"}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, opacity: .7 }}>위도 {p.latitude}</div>
|
||||
<div style={{ fontSize: 12, opacity: .7 }}>경도 {p.longitude}</div>
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={() => { setEditingId(p.id); setEditDraft({ name: p.name, category: p.category, latitude: String(p.latitude), longitude: String(p.longitude), address: p.address ?? "", imageUrl: p.imageUrl ?? "", sortOrder: p.sortOrder ?? 0 }); setShowEditModal(true); }}
|
||||
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "DELETE" }); mutate(); }}
|
||||
className="h-8 px-3 rounded-md border border-red-200 text-red-600 text-sm cursor-pointer hover:bg-red-100 hover:border-red-300 hover:text-red-700 active:translate-y-px transition"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<button
|
||||
title="위로"
|
||||
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: (p.sortOrder ?? 0) - 1 }) }); mutate(); }}
|
||||
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
title="아래로"
|
||||
onClick={async () => { await fetch(`/api/admin/partners/${p.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ sortOrder: (p.sortOrder ?? 0) + 1 }) }); mutate(); }}
|
||||
className="h-8 px-3 rounded-md border border-neutral-300 text-sm cursor-pointer hover:bg-neutral-100 active:translate-y-px transition"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
21
src/app/api/admin/partner-shops/[id]/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await context.params;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const data: any = {};
|
||||
for (const k of ["region", "name", "address", "imageUrl", "active", "sortOrder"]) {
|
||||
if (k in body) data[k] = body[k];
|
||||
}
|
||||
const item = await prisma.partnerShop.update({ where: { id }, data });
|
||||
return NextResponse.json({ item });
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await context.params;
|
||||
await prisma.partnerShop.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
27
src/app/api/admin/partner-shops/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function GET() {
|
||||
const items = await prisma.partnerShop.findMany({ orderBy: [{ active: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }] });
|
||||
return NextResponse.json({ items });
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
region: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
address: z.string().min(1),
|
||||
imageUrl: z.string().min(1),
|
||||
sortOrder: z.coerce.number().int().optional(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const item = await prisma.partnerShop.create({ data: parsed.data });
|
||||
return NextResponse.json({ item }, { status: 201 });
|
||||
}
|
||||
|
||||
|
||||
34
src/app/api/admin/partners/[id]/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await context.params;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const data: any = {};
|
||||
for (const k of ["name", "category", "latitude", "longitude", "address", "imageUrl", "sortOrder"]) {
|
||||
if (k in body) data[k] = body[k];
|
||||
}
|
||||
if (typeof data.latitude !== "undefined") data.latitude = Number(data.latitude);
|
||||
if (typeof data.longitude !== "undefined") data.longitude = Number(data.longitude);
|
||||
if (typeof data.sortOrder !== "undefined") data.sortOrder = Number(data.sortOrder);
|
||||
try {
|
||||
const partner = await prisma.partner.update({ where: { id }, data });
|
||||
return NextResponse.json({ partner });
|
||||
} catch (e) {
|
||||
// DB에 sortOrder 컬럼이 아직 없는 환경 대비: 해당 키 제거 후 재시도
|
||||
if (Object.prototype.hasOwnProperty.call(data, "sortOrder")) {
|
||||
const { sortOrder, ...rest } = data;
|
||||
const partner = await prisma.partner.update({ where: { id }, data: rest });
|
||||
return NextResponse.json({ partner });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await context.params;
|
||||
await prisma.partner.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
40
src/app/api/admin/partners/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// 정렬용 컬럼(sortOrder)이 있는 경우 우선 사용
|
||||
const partners = await (prisma as any).partner.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }] });
|
||||
return NextResponse.json({ partners });
|
||||
} catch (_) {
|
||||
// 컬럼이 아직 DB에 없으면 createdAt 기준으로 폴백
|
||||
const partners = await prisma.partner.findMany({ orderBy: { createdAt: "desc" } });
|
||||
return NextResponse.json({ partners });
|
||||
}
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
category: z.string().min(1),
|
||||
latitude: z.coerce.number(),
|
||||
longitude: z.coerce.number(),
|
||||
address: z.string().min(1).optional(),
|
||||
imageUrl: z
|
||||
.string()
|
||||
.refine((v) => !v || /^https?:\/\//.test(v) || v.startsWith("/"), {
|
||||
message: "imageUrl must be http(s) URL or start with /",
|
||||
})
|
||||
.optional(),
|
||||
sortOrder: z.coerce.number().int().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const partner = await prisma.partner.create({ data: parsed.data as any });
|
||||
return NextResponse.json({ partner }, { status: 201 });
|
||||
}
|
||||
|
||||
|
||||
12
src/app/api/partner-shops/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const items = await prisma.partnerShop.findMany({
|
||||
where: { active: true },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
return NextResponse.json({ items });
|
||||
}
|
||||
|
||||
|
||||
@@ -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.id}`}>{b.name}</Link></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import useSWR from "swr";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import ViewsIcon from "@/app/svgs/ViewsIcon";
|
||||
import LikeIcon from "@/app/svgs/LikeIcon";
|
||||
@@ -129,14 +130,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>
|
||||
)}
|
||||
@@ -227,9 +228,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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -31,8 +32,8 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
||||
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">
|
||||
<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.id}`} className="text-lg md:text-xl font-bold text-neutral-800 truncate">{board.name}</Link>
|
||||
<Link href={`/boards/${board.id}`} 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} />
|
||||
@@ -49,21 +50,29 @@ 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개) */}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||