Merge branch 'mainwork' into subwork

This commit is contained in:
koreacomp5
2025-11-02 04:40:04 +09:00
15 changed files with 28 additions and 28 deletions

View File

@@ -124,7 +124,6 @@ model Board {
description String? description String?
sortOrder Int @default(0) sortOrder Int @default(0)
status BoardStatus @default(active) status BoardStatus @default(active)
requiresApproval Boolean @default(false) // 게시물 승인 필요 여부
allowAnonymousPost Boolean @default(false) // 익명 글 허용 allowAnonymousPost Boolean @default(false) // 익명 글 허용
allowSecretComment Boolean @default(false) // 비밀댓글 허용 allowSecretComment Boolean @default(false) // 비밀댓글 허용
isAdultOnly Boolean @default(false) // 성인 인증 필요 여부 isAdultOnly Boolean @default(false) // 성인 인증 필요 여부

View File

@@ -200,6 +200,10 @@ async function upsertBoards(admin, categoryMap) {
]; ];
const created = []; const created = [];
// 특수 랭킹 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨)
const mainSpecial = await prisma.boardViewType.findUnique({ where: { key: "main_special_rank" } });
const listSpecial = await prisma.boardViewType.findUnique({ where: { key: "list_special_rank" } });
for (const b of boards) { for (const b of boards) {
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리) // 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
const mapBySlug = { const mapBySlug = {
@@ -227,20 +231,22 @@ async function upsertBoards(admin, categoryMap) {
update: { update: {
description: b.description, description: b.description,
sortOrder: b.sortOrder, sortOrder: b.sortOrder,
requiresApproval: !!b.requiresApproval,
allowAnonymousPost: !!b.allowAnonymousPost, allowAnonymousPost: !!b.allowAnonymousPost,
readLevel: b.readLevel || undefined, readLevel: b.readLevel || undefined,
categoryId: category ? category.id : undefined, categoryId: category ? category.id : undefined,
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
}, },
create: { create: {
name: b.name, name: b.name,
slug: b.slug, slug: b.slug,
description: b.description, description: b.description,
sortOrder: b.sortOrder, sortOrder: b.sortOrder,
requiresApproval: !!b.requiresApproval,
allowAnonymousPost: !!b.allowAnonymousPost, allowAnonymousPost: !!b.allowAnonymousPost,
readLevel: b.readLevel || undefined, readLevel: b.readLevel || undefined,
categoryId: category ? category.id : undefined, categoryId: category ? category.id : undefined,
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
}, },
}); });
created.push(board); created.push(board);

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View File

@@ -368,7 +368,6 @@ export default function AdminBoardsPage() {
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
<th className="px-3 py-2"> </th> <th className="px-3 py-2"> </th>
<th className="px-3 py-2"></th> <th className="px-3 py-2"></th>
@@ -501,7 +500,6 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
</td> </td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => { const v = { ...edit, allowAnonymousPost: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td> <td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => { const v = { ...edit, allowAnonymousPost: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowSecretComment} onChange={(e) => { const v = { ...edit, allowSecretComment: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td> <td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowSecretComment} onChange={(e) => { const v = { ...edit, allowSecretComment: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.requiresApproval} onChange={(e) => { const v = { ...edit, requiresApproval: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => { const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td> <td className="px-3 py-2 text-center"><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => { const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
{allowMove && categories && onMove ? ( {allowMove && categories && onMove ? (
<td className="px-3 py-2 text-center"> <td className="px-3 py-2 text-center">

View File

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

View File

@@ -16,7 +16,6 @@ export async function GET() {
writeLevel: true, writeLevel: true,
allowAnonymousPost: true, allowAnonymousPost: true,
allowSecretComment: true, allowSecretComment: true,
requiresApproval: true,
status: true, status: true,
categoryId: true, categoryId: true,
mainPageViewTypeId: true, mainPageViewTypeId: true,
@@ -36,7 +35,6 @@ const createSchema = z.object({
writeLevel: z.string().optional(), writeLevel: z.string().optional(),
allowAnonymousPost: z.boolean().optional(), allowAnonymousPost: z.boolean().optional(),
allowSecretComment: z.boolean().optional(), allowSecretComment: z.boolean().optional(),
requiresApproval: z.boolean().optional(),
status: z.string().optional(), status: z.string().optional(),
isAdultOnly: z.boolean().optional(), isAdultOnly: z.boolean().optional(),
categoryId: z.string().nullable().optional(), categoryId: z.string().nullable().optional(),

View File

@@ -20,7 +20,6 @@ export async function GET(req: Request) {
name: true, name: true,
slug: true, slug: true,
description: true, description: true,
requiresApproval: true,
allowAnonymousPost: true, allowAnonymousPost: true,
isAdultOnly: true, isAdultOnly: true,
category: { select: { id: true, name: true, slug: true } }, category: { select: { id: true, name: true, slug: true } },

View File

@@ -10,7 +10,7 @@ export async function GET() {
boards: { boards: {
where: { status: "active" }, where: { status: "active" },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: { id: true, name: true, slug: true, requiresApproval: true }, select: { id: true, name: true, slug: true },
}, },
}, },
}); });

View File

@@ -18,7 +18,6 @@ export async function POST(req: Request) {
} }
const { boardId, authorId, title, content, isAnonymous } = parsed.data; const { boardId, authorId, title, content, isAnonymous } = parsed.data;
const board = await prisma.board.findUnique({ where: { id: boardId } }); const board = await prisma.board.findUnique({ where: { id: boardId } });
const requiresApproval = board?.requiresApproval ?? false;
// 사진형 보드 필수 이미지 검증: content 내 이미지 링크 최소 1개 // 사진형 보드 필수 이미지 검증: content 내 이미지 링크 최소 1개
const isImageOnly = (board?.requiredFields as any)?.imageOnly; const isImageOnly = (board?.requiredFields as any)?.imageOnly;
const minImages = (board?.requiredFields as any)?.minImages ?? 0; const minImages = (board?.requiredFields as any)?.minImages ?? 0;
@@ -35,7 +34,7 @@ export async function POST(req: Request) {
title, title,
content, content,
isAnonymous: !!isAnonymous, isAnonymous: !!isAnonymous,
status: requiresApproval ? "hidden" : "published", status: "published",
}, },
}); });
return NextResponse.json({ post }, { status: 201 }); return NextResponse.json({ post }, { status: 201 });

View File

@@ -8,7 +8,7 @@ import prisma from "@/lib/prisma";
export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) { export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) {
const p = params?.then ? await params : params; const p = params?.then ? await params : params;
const sp = searchParams?.then ? await searchParams : searchParams; const sp = searchParams?.then ? await searchParams : searchParams;
const id = p.id as string; const idOrSlug = p.id as string;
const sort = (sp?.sort as "recent" | "popular" | undefined) ?? "recent"; const sort = (sp?.sort as "recent" | "popular" | undefined) ?? "recent";
const period = (sp?.period as string | undefined) ?? "monthly"; const period = (sp?.period as string | undefined) ?? "monthly";
// 보드 slug 조회 (새 글 페이지 프리셋 전달) // 보드 slug 조회 (새 글 페이지 프리셋 전달)
@@ -18,7 +18,8 @@ export default async function BoardDetail({ params, searchParams }: { params: an
const base = process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`; const base = process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`;
const res = await fetch(new URL("/api/boards", base).toString(), { cache: "no-store" }); const res = await fetch(new URL("/api/boards", base).toString(), { cache: "no-store" });
const { boards } = await res.json(); const { boards } = await res.json();
const board = (boards || []).find((b: any) => b.id === id); const board = (boards || []).find((b: any) => b.slug === idOrSlug || b.id === idOrSlug);
const id = board?.id as string;
const siblingBoards = (boards || []).filter((b: any) => b.category?.id && b.category.id === board?.category?.id); const siblingBoards = (boards || []).filter((b: any) => b.category?.id && b.category.id === board?.category?.id);
const categoryName = board?.category?.name ?? ""; const categoryName = board?.category?.name ?? "";
@@ -41,14 +42,14 @@ export default async function BoardDetail({ params, searchParams }: { params: an
{/* 상단 배너 (서브카테고리 표시) */} {/* 상단 배너 (서브카테고리 표시) */}
<section> <section>
<HeroBanner <HeroBanner
subItems={siblingBoards.map((b: any) => ({ id: b.id, name: b.name, href: `/boards/${b.id}` }))} subItems={siblingBoards.map((b: any) => ({ id: b.id, name: b.name, href: `/boards/${b.slug}` }))}
activeSubId={id} activeSubId={id}
/> />
</section> </section>
{/* 검색/필터 툴바 + 리스트 */} {/* 검색/필터 툴바 + 리스트 */}
<section> <section>
<BoardToolbar boardId={id} /> <BoardToolbar boardId={board?.slug} />
<div className="p-0"> <div className="p-0">
{isSpecialRanking ? ( {isSpecialRanking ? (
<div className="rounded-xl border border-neutral-200 overflow-hidden"> <div className="rounded-xl border border-neutral-200 overflow-hidden">

View File

@@ -15,7 +15,7 @@ export default async function BoardsPage() {
<h1></h1> <h1></h1>
<ul style={{ display: "flex", flexDirection: "column", gap: 6 }}> <ul style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{boards?.map((b: any) => ( {boards?.map((b: any) => (
<li key={b.id}><Link href={`/boards/${b.id}`}>{b.name}</Link></li> <li key={b.id}><Link href={`/boards/${b.slug}`}>{b.name}</Link></li>
))} ))}
</ul> </ul>
</div> </div>

View File

@@ -35,7 +35,7 @@ export function AppHeader() {
}, [pathname]); }, [pathname]);
const activeCategorySlug = React.useMemo(() => { const activeCategorySlug = React.useMemo(() => {
if (activeBoardId) { if (activeBoardId) {
const found = categories.find((c) => c.boards.some((b) => b.id === activeBoardId)); const found = categories.find((c) => c.boards.some((b) => b.slug === activeBoardId));
return found?.slug ?? null; return found?.slug ?? null;
} }
if (pathname === "/boards") { if (pathname === "/boards") {
@@ -337,7 +337,7 @@ export function AppHeader() {
style={idx === categories.length - 1 ? { minWidth: 120 } : undefined} style={idx === categories.length - 1 ? { minWidth: 120 } : undefined}
> >
<Link <Link
href={cat.boards?.[0]?.id ? `/boards/${cat.boards[0].id}` : `/boards?category=${cat.slug}`} href={cat.boards?.[0]?.slug ? `/boards/${cat.boards[0].slug}` : `/boards?category=${cat.slug}`}
className={`block w-full px-2 py-2 text-sm font-medium transition-colors duration-200 hover:text-neutral-900 whitespace-nowrap ${ className={`block w-full px-2 py-2 text-sm font-medium transition-colors duration-200 hover:text-neutral-900 whitespace-nowrap ${
activeCategorySlug === cat.slug ? "text-neutral-900" : "text-neutral-700" activeCategorySlug === cat.slug ? "text-neutral-900" : "text-neutral-700"
}`} }`}
@@ -381,11 +381,11 @@ export function AppHeader() {
{cat.boards.map((b) => ( {cat.boards.map((b) => (
<Link <Link
key={b.id} key={b.id}
href={`/boards/${b.id}`} href={`/boards/${b.slug}`}
className={`rounded px-2 py-1 text-sm transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-900 text-center whitespace-nowrap ${ className={`rounded px-2 py-1 text-sm transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-900 text-center whitespace-nowrap ${
activeBoardId === b.id ? "bg-neutral-100 text-neutral-900 font-medium" : "text-neutral-700" activeBoardId === b.slug ? "bg-neutral-100 text-neutral-900 font-medium" : "text-neutral-700"
}`} }`}
aria-current={activeBoardId === b.id ? "page" : undefined} aria-current={activeBoardId === b.slug ? "page" : undefined}
> >
{b.name} {b.name}
</Link> </Link>
@@ -416,7 +416,7 @@ export function AppHeader() {
<div className="mb-2 font-semibold text-neutral-800">{cat.name}</div> <div className="mb-2 font-semibold text-neutral-800">{cat.name}</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{cat.boards.map((b) => ( {cat.boards.map((b) => (
<Link key={b.id} href={`/boards/${b.id}`} onClick={() => setMobileOpen(false)} className="rounded px-2 py-1 text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900"> <Link key={b.id} href={`/boards/${b.slug}`} onClick={() => setMobileOpen(false)} className="rounded px-2 py-1 text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900">
{b.name} {b.name}
</Link> </Link>
))} ))}

View File

@@ -7,7 +7,7 @@ type ApiCategory = {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
boards: { id: string; name: string; slug: string; requiresApproval: boolean }[]; boards: { id: string; name: string; slug: string }[];
}; };
type PostItem = { type PostItem = {
@@ -101,7 +101,7 @@ export default function CategoryBoardBrowser({ categoryName, categorySlug }: Pro
className="shrink-0 text-lg md:text-xl font-bold text-neutral-800 truncate" className="shrink-0 text-lg md:text-xl font-bold text-neutral-800 truncate"
onClick={() => { onClick={() => {
const first = selectedCategory?.boards?.[0]; const first = selectedCategory?.boards?.[0];
if (first?.id) router.push(`/boards/${first.id}`); if (first?.slug) router.push(`/boards/${first.slug}`);
}} }}
title={(selectedCategory?.name ?? categoryName ?? "").toString()} title={(selectedCategory?.name ?? categoryName ?? "").toString()}
> >
@@ -113,7 +113,7 @@ export default function CategoryBoardBrowser({ categoryName, categorySlug }: Pro
className="shrink-0 w-6 h-6 rounded-full border border-neutral-300 text-neutral-500 hover:bg-neutral-50 flex items-center justify-center" className="shrink-0 w-6 h-6 rounded-full border border-neutral-300 text-neutral-500 hover:bg-neutral-50 flex items-center justify-center"
onClick={() => { onClick={() => {
const first = selectedCategory?.boards?.[0]; const first = selectedCategory?.boards?.[0];
if (first?.id) router.push(`/boards/${first.id}`); if (first?.slug) router.push(`/boards/${first.slug}`);
}} }}
> >
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">

View File

@@ -8,7 +8,7 @@ import { HeroBanner } from "@/app/components/HeroBanner";
export default function EditPostPage() { export default function EditPostPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const id = params?.id as string; const id = params?.id as string; // slug 값
const router = useRouter(); const router = useRouter();
const { show } = useToast(); const { show } = useToast();
const [form, setForm] = useState<{ title: string; content: string } | null>(null); const [form, setForm] = useState<{ title: string; content: string } | null>(null);

View File

@@ -5,7 +5,7 @@ import { HeroBanner } from "@/app/components/HeroBanner";
// 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다. // 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다.
export default async function PostDetail({ params }: { params: any }) { export default async function PostDetail({ params }: { params: any }) {
const p = params?.then ? await params : params; const p = params?.then ? await params : params;
const id = p.id as string; const id = p.id as string; // slug 값이 들어옴
const h = await headers(); const h = await headers();
const host = h.get("host") ?? "localhost:3000"; const host = h.get("host") ?? "localhost:3000";
const proto = h.get("x-forwarded-proto") ?? "http"; const proto = h.get("x-forwarded-proto") ?? "http";