10.4 공지/배너 등록 및 노출 설정 o

This commit is contained in:
koreacomp5
2025-10-09 18:42:50 +09:00
parent 375f4c5681
commit 0d18893374
9 changed files with 170 additions and 12 deletions

View File

@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "banners" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"imageUrl" TEXT NOT NULL,
"linkUrl" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"startAt" DATETIME,
"endAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE INDEX "banners_active_sortOrder_idx" ON "banners"("active", "sortOrder");
-- CreateIndex
CREATE INDEX "banners_startAt_endAt_idx" ON "banners"("startAt", "endAt");

View File

@@ -632,6 +632,24 @@ model Partner {
@@map("partners") @@map("partners")
} }
// 배너/공지 노출용
model Banner {
id String @id @default(cuid())
title String
imageUrl String
linkUrl String?
active Boolean @default(true)
sortOrder Int @default(0)
startAt DateTime?
endAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([active, sortOrder])
@@index([startAt, endAt])
@@map("banners")
}
// 제휴 문의 // 제휴 문의
model PartnerInquiry { model PartnerInquiry {
id String @id @default(cuid()) id String @id @default(cuid())

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 754 KiB

After

Width:  |  Height:  |  Size: 781 KiB

View File

@@ -0,0 +1,43 @@
"use client";
import useSWR from "swr";
import { useState } from "react";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
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 });
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(); }
}
return (
<div>
<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>
</div>
<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" }}>
<img src={b.imageUrl} alt={b.title} style={{ width: 80, height: 48, objectFit: "cover", borderRadius: 6 }} />
<div style={{ flex: 1 }}>
<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>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,23 @@
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 ["title", "imageUrl", "linkUrl", "active", "sortOrder", "startAt", "endAt"]) {
if (k in body) data[k] = body[k];
}
if (data.startAt) data.startAt = new Date(data.startAt);
if (data.endAt) data.endAt = new Date(data.endAt);
const banner = await prisma.banner.update({ where: { id }, data });
return NextResponse.json({ banner });
}
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
await prisma.banner.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { z } from "zod";
export async function GET() {
const banners = await prisma.banner.findMany({ orderBy: [{ active: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }] });
return NextResponse.json({ banners });
}
const createSchema = z.object({
title: z.string().min(1),
imageUrl: z.string().url(),
linkUrl: z.string().url().optional(),
active: z.boolean().optional(),
sortOrder: z.coerce.number().int().optional(),
startAt: z.coerce.date().optional(),
endAt: z.coerce.date().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 banner = await prisma.banner.create({ data: parsed.data });
return NextResponse.json({ banner }, { status: 201 });
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function GET() {
const now = new Date();
const banners = await prisma.banner.findMany({
where: {
active: true,
AND: [
{ OR: [{ startAt: null }, { startAt: { lte: now } }] },
{ OR: [{ endAt: null }, { endAt: { gte: now } }] },
],
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
return NextResponse.json({ banners });
}

View File

@@ -1,22 +1,30 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
const defaultSlides = [ type Banner = { id: string; title: string; imageUrl: string; linkUrl?: string | null };
{ id: 1, title: "공지사항", subtitle: "중요 공지 확인하기" },
{ id: 2, title: "이벤트", subtitle: "진행중인 이벤트" },
];
export function HeroBanner() { export function HeroBanner() {
const [banners, setBanners] = useState<Banner[]>([]);
const [idx, setIdx] = useState(0); const [idx, setIdx] = useState(0);
useEffect(() => { useEffect(() => {
const t = setInterval(() => setIdx((i) => (i + 1) % defaultSlides.length), 3000); fetch("/api/banners").then((r) => r.json()).then((d) => setBanners(d.banners ?? []));
return () => clearInterval(t);
}, []); }, []);
const slide = defaultSlides[idx]; useEffect(() => {
return ( if ((banners?.length ?? 0) < 2) return;
<section style={{ padding: 24, background: "#f5f5f5", borderRadius: 12, marginBottom: 16 }}> const t = setInterval(() => setIdx((i) => (i + 1) % banners.length), 3000);
return () => clearInterval(t);
}, [banners]);
const slide = banners[idx];
if (!slide) return null;
const content = (
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<img src={slide.imageUrl} alt={slide.title} style={{ width: 72, height: 48, objectFit: "cover", borderRadius: 6 }} />
<h1 style={{ margin: 0 }}>{slide.title}</h1> <h1 style={{ margin: 0 }}>{slide.title}</h1>
<p style={{ margin: 0, opacity: 0.8 }}>{slide.subtitle}</p> </div>
);
return (
<section style={{ padding: 16, background: "#f5f5f5", borderRadius: 12, marginBottom: 16 }}>
{slide.linkUrl ? <a href={slide.linkUrl}>{content}</a> : content}
</section> </section>
); );
} }

View File

@@ -72,7 +72,7 @@
10.1 대시보드 핵심 지표 위젯 o 10.1 대시보드 핵심 지표 위젯 o
10.2 게시판 스키마/설정 관리 UI o 10.2 게시판 스키마/설정 관리 UI o
10.3 사용자 검색/정지/권한 변경 o 10.3 사용자 검색/정지/권한 변경 o
10.4 공지/배너 등록 및 노출 설정 10.4 공지/배너 등록 및 노출 설정 o
10.5 감사 이력/신고 내역/열람 로그 10.5 감사 이력/신고 내역/열람 로그
10.6 카테고리 유형/설정 관리(일반/특수/승인/레벨/익명/태그) 10.6 카테고리 유형/설정 관리(일반/특수/승인/레벨/익명/태그)