06 · Developer Guide · 레시피
관리자 글쓰기 페이지Admin CMS
운영자가 코드를 만지지 않고 게시물을 직접 쓰고 발행하는 화면입니다.
MDX 편집기 + Supabase Storage 이미지 업로드 + 발행/임시저장 분기로 구성됩니다.
언제 쓰는가
- 새소식·주보·함즐함울처럼 운영자가 매주 새 글을 등록하는 게시판
- 설교 영상 추가·인물 정보 수정처럼 정기적인 콘텐츠 변경
- 임시 저장 → 검토 → 발행 흐름이 필요한 경우
① 권한 가드 (레이아웃에서 한 번)
관리자 영역의 모든 페이지가 공유하는 layout에서 권한 체크. 페이지마다 반복하지 않습니다. RLS는 클라이언트 차단까지는 못 하므로 서버에서 한 번 더 검사.
app/(site)/site/admin/layout.tsxtsx
// app/(site)/site/admin/layout.tsx — 관리자 전용 레이아웃
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/site/auth/login?next=/site/admin");
const { data: profile } = await supabase
.from("profiles")
.select("role, nickname")
.eq("id", user.id)
.single();
if (!profile || !["staff", "admin"].includes(profile.role)) {
redirect("/site?error=forbidden");
}
return (
<div className="grid grid-cols-[220px_1fr] gap-8 px-6 py-10">
<AdminSidebar role={profile.role} />
<main>{children}</main>
</div>
);
}② 게시물 목록 — 종류 탭 + 새 글 버튼
app/(site)/site/admin/posts/page.tsxtsx
// app/(site)/site/admin/posts/page.tsx — 게시물 목록
import Link from "next/link";
import { createClient } from "@/lib/supabase/server";
import { formatDate } from "@/lib/utils/date";
export default async function AdminPostsPage({
searchParams,
}: {
searchParams: Promise<{ kind?: string }>;
}) {
const { kind = "news" } = await searchParams;
const supabase = await createClient();
const { data: posts } = await supabase
.from("posts")
.select("id, slug, title, published_at, kind")
.eq("kind", kind)
.order("created_at", { ascending: false })
.limit(50);
return (
<div>
<div className="flex items-center justify-between">
<h1 className="text-[22px] font-bold">게시물 관리</h1>
<Link href={`/site/admin/posts/new?kind=${kind}`} className="rounded-md bg-accent px-4 py-2 text-white">
새 글
</Link>
</div>
<KindTabs current={kind} />
<table className="mt-6 w-full text-[14px]">
{/* 제목 · 발행일 · 상태 · 액션 */}
<tbody>
{posts?.map((p) => (
<tr key={p.id} className="border-b">
<td className="py-3">
<Link href={`/site/admin/posts/${p.id}`}>{p.title}</Link>
</td>
<td className="text-text-muted">
{p.published_at ? formatDate(p.published_at) : "작성 중"}
</td>
<td>
<StatusBadge published={!!p.published_at} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}③ 편집기 — MDX + 자동 저장
tsx
// app/(site)/site/admin/posts/[id]/page.tsx — 글 편집기
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { MdxEditor } from "@/components/admin/MdxEditor";
export function PostEditor({ initial }: { initial: Post }) {
const supabase = createClient();
const [post, setPost] = useState(initial);
const [saving, setSaving] = useState(false);
const save = async ({ publish }: { publish: boolean }) => {
setSaving(true);
const payload = {
...post,
published_at: publish ? new Date().toISOString() : post.published_at,
};
const { error } = await supabase
.from("posts")
.update(payload)
.eq("id", post.id);
setSaving(false);
if (error) {
toast.error("저장 실패: " + error.message);
return;
}
toast.success(publish ? "발행했습니다." : "임시 저장했습니다.");
};
return (
<form onSubmit={(e) => e.preventDefault()} className="space-y-6">
<input
value={post.title}
onChange={(e) => setPost({ ...post, title: e.target.value })}
placeholder="제목"
className="w-full border-b py-3 text-[22px] font-bold focus:outline-none"
/>
<MdxEditor
value={post.body ?? ""}
onChange={(body) => setPost({ ...post, body })}
/>
<div className="flex gap-3">
<button onClick={() => save({ publish: false })} disabled={saving}>
임시 저장
</button>
<button
onClick={() => save({ publish: true })}
disabled={saving}
className="rounded-md bg-accent px-5 py-2 text-white"
>
발행
</button>
</div>
</form>
);
}④ 본문 이미지 업로드 — Supabase Storage
ts
// 글 안의 이미지 업로드 → Supabase Storage
const uploadImage = async (file: File) => {
const supabase = createClient();
const path = `posts/${crypto.randomUUID()}-${file.name}`;
const { data, error } = await supabase.storage
.from("public-assets")
.upload(path, file, { cacheControl: "31536000", upsert: false });
if (error) throw error;
const { data: { publicUrl } } = supabase.storage
.from("public-assets")
.getPublicUrl(data.path);
return publicUrl;
};
// MdxEditor 콜백
<MdxEditor onUploadImage={uploadImage} />