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} />

설계 포인트

Need Help

도움이 필요하신가요?

주님의교회 PCL 디자인 시스템을 적용하시다가 막히는 부분이 있다면, 다음 경로로 직접 문의하실 수 있습니다.