Compare commits

..

3 Commits

Author SHA1 Message Date
koreacomp5
c6e60cd34d Merge branch 'mainwork' into subwork 2025-11-02 04:40:04 +09:00
koreacomp5
c7f7492b9e Merge branch 'subwork' into mainwork 2025-11-02 04:39:42 +09:00
koreacomp5
4d310346c1 main 2025-11-02 04:39:28 +09:00
15 changed files with 28 additions and 28 deletions

View File

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

View File

@@ -200,6 +200,10 @@ async function upsertBoards(admin, categoryMap) {
];
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) {
// 카테고리 매핑 규칙 (트리 기준 상위 카테고리)
const mapBySlug = {
@@ -227,20 +231,22 @@ async function upsertBoards(admin, categoryMap) {
update: {
description: b.description,
sortOrder: b.sortOrder,
requiresApproval: !!b.requiresApproval,
allowAnonymousPost: !!b.allowAnonymousPost,
readLevel: b.readLevel || undefined,
categoryId: category ? category.id : undefined,
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
},
create: {
name: b.name,
slug: b.slug,
description: b.description,
sortOrder: b.sortOrder,
requiresApproval: !!b.requiresApproval,
allowAnonymousPost: !!b.allowAnonymousPost,
readLevel: b.readLevel || undefined,
categoryId: category ? category.id : undefined,
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
},
});
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>
@@ -501,7 +500,6 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma
</td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowAnonymousPost} onChange={(e) => { const v = { ...edit, allowAnonymousPost: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.allowSecretComment} onChange={(e) => { const v = { ...edit, allowSecretComment: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={edit.requiresApproval} onChange={(e) => { const v = { ...edit, requiresApproval: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
<td className="px-3 py-2 text-center"><input type="checkbox" checked={!!edit.isAdultOnly} onChange={(e) => { const v = { ...edit, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /></td>
{allowMove && categories && onMove ? (
<td className="px-3 py-2 text-center">

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ export async function GET() {
boards: {
where: { status: "active" },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: { id: true, name: true, slug: true, requiresApproval: true },
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 board = await prisma.board.findUnique({ where: { id: boardId } });
const requiresApproval = board?.requiresApproval ?? false;
// 사진형 보드 필수 이미지 검증: content 내 이미지 링크 최소 1개
const isImageOnly = (board?.requiredFields as any)?.imageOnly;
const minImages = (board?.requiredFields as any)?.minImages ?? 0;
@@ -35,7 +34,7 @@ export async function POST(req: Request) {
title,
content,
isAnonymous: !!isAnonymous,
status: requiresApproval ? "hidden" : "published",
status: "published",
},
});
return NextResponse.json({ post }, { status: 201 });

View File

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

View File

@@ -15,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}><Link href={`/boards/${b.id}`}>{b.name}</Link></li>
<li key={b.id}><Link href={`/boards/${b.slug}`}>{b.name}</Link></li>
))}
</ul>
</div>

View File

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

View File

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

View File

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

View File

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