diff --git a/app/admin/(dashboard)/authors/[name]/edit/page.tsx b/app/admin/(dashboard)/authors/[name]/edit/page.tsx
new file mode 100644
index 0000000..949f016
--- /dev/null
+++ b/app/admin/(dashboard)/authors/[name]/edit/page.tsx
@@ -0,0 +1,116 @@
+import { createClient } from "@/utils/supabase/server";
+import { notFound } from "next/navigation";
+import { updateAuthor } from "../../actions";
+
+interface PageProps {
+ params: Promise<{ name: string }>;
+}
+
+export default async function EditAuthorPage({ params }: PageProps) {
+ const { name } = await params;
+ const decodedName = decodeURIComponent(name);
+ const supabase = await createClient();
+
+ const { data: author, error } = await supabase
+ .from("authors")
+ .select("name, bio, x_link, fb_link, insta_link, pfp_link")
+ .eq("name", decodedName)
+ .single();
+
+ if (error || !author) {
+ notFound();
+ }
+
+ return (
+
+ );
+}
diff --git a/app/admin/(dashboard)/authors/actions.ts b/app/admin/(dashboard)/authors/actions.ts
new file mode 100644
index 0000000..e1f2350
--- /dev/null
+++ b/app/admin/(dashboard)/authors/actions.ts
@@ -0,0 +1,71 @@
+"use server";
+
+import { createClient } from "@/utils/supabase/server";
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+
+export async function createAuthor(formData: FormData) {
+ const supabase = await createClient();
+
+ const name = formData.get("name") as string;
+ const bio = formData.get("bio") as string;
+ const x_link = (formData.get("x_link") as string) || null;
+ const fb_link = (formData.get("fb_link") as string) || null;
+ const insta_link = (formData.get("insta_link") as string) || null;
+ const pfp_link = (formData.get("pfp_link") as string) || null;
+
+ const { error } = await supabase.from("authors").insert({
+ name,
+ bio,
+ x_link,
+ fb_link,
+ insta_link,
+ pfp_link,
+ });
+
+ if (error) {
+ throw new Error(`Failed to create author: ${error.message}`);
+ }
+
+ revalidatePath("/authors");
+ revalidatePath("/admin/authors");
+ redirect("/admin/authors");
+}
+
+export async function updateAuthor(formData: FormData) {
+ const supabase = await createClient();
+
+ const originalName = formData.get("originalName") as string;
+ const name = formData.get("name") as string;
+ const bio = formData.get("bio") as string;
+ const x_link = (formData.get("x_link") as string) || null;
+ const fb_link = (formData.get("fb_link") as string) || null;
+ const insta_link = (formData.get("insta_link") as string) || null;
+ const pfp_link = (formData.get("pfp_link") as string) || null;
+
+ const { error } = await supabase
+ .from("authors")
+ .update({ name, bio, x_link, fb_link, insta_link, pfp_link })
+ .eq("name", originalName);
+
+ if (error) {
+ throw new Error(`Failed to update author: ${error.message}`);
+ }
+
+ revalidatePath("/authors");
+ revalidatePath("/admin/authors");
+ redirect("/admin/authors");
+}
+
+export async function deleteAuthor(name: string) {
+ const supabase = await createClient();
+
+ const { error } = await supabase.from("authors").delete().eq("name", name);
+
+ if (error) {
+ throw new Error(`Failed to delete author: ${error.message}`);
+ }
+
+ revalidatePath("/authors");
+ revalidatePath("/admin/authors");
+}
diff --git a/app/admin/(dashboard)/authors/new/page.tsx b/app/admin/(dashboard)/authors/new/page.tsx
new file mode 100644
index 0000000..d3ca581
--- /dev/null
+++ b/app/admin/(dashboard)/authors/new/page.tsx
@@ -0,0 +1,89 @@
+import { createAuthor } from "../actions";
+
+export default function NewAuthorPage() {
+ return (
+
+ );
+}
diff --git a/app/admin/(dashboard)/authors/page.tsx b/app/admin/(dashboard)/authors/page.tsx
new file mode 100644
index 0000000..22583df
--- /dev/null
+++ b/app/admin/(dashboard)/authors/page.tsx
@@ -0,0 +1,100 @@
+import { createClient } from "@/utils/supabase/server";
+import Link from "next/link";
+import { deleteAuthor } from "./actions";
+
+export default async function AdminAuthorsPage() {
+ const supabase = await createClient();
+
+ const { data: authors } = await supabase
+ .from("authors")
+ .select("name, bio, x_link, fb_link, insta_link, pfp_link")
+ .order("name");
+
+ return (
+
+
+
Authors
+
+ New Author
+
+
+
+
+
+
+
+ |
+ Name
+ |
+
+ Bio
+ |
+
+ Links
+ |
+
+ Actions
+ |
+
+
+
+ {authors?.map((author) => (
+
+ | {author.name} |
+
+
+ {author.bio}
+
+ |
+
+
+ {author.x_link && (
+
+ X
+
+ )}
+ {author.fb_link && (
+
+ FB
+
+ )}
+ {author.insta_link && (
+
+ IG
+
+ )}
+
+ |
+
+
+ Edit
+
+
+ |
+
+ ))}
+
+
+ {(!authors || authors.length === 0) && (
+
No authors found.
+ )}
+
+
+ );
+}
diff --git a/app/admin/(dashboard)/comments/actions.ts b/app/admin/(dashboard)/comments/actions.ts
new file mode 100644
index 0000000..8017160
--- /dev/null
+++ b/app/admin/(dashboard)/comments/actions.ts
@@ -0,0 +1,16 @@
+"use server";
+
+import { createClient } from "@/utils/supabase/server";
+import { revalidatePath } from "next/cache";
+
+export async function deleteComment(id: number) {
+ const supabase = await createClient();
+
+ const { error } = await supabase.from("comments").delete().eq("id", id);
+
+ if (error) {
+ throw new Error(`Failed to delete comment: ${error.message}`);
+ }
+
+ revalidatePath("/admin/comments");
+}
diff --git a/app/admin/(dashboard)/comments/page.tsx b/app/admin/(dashboard)/comments/page.tsx
new file mode 100644
index 0000000..acfe047
--- /dev/null
+++ b/app/admin/(dashboard)/comments/page.tsx
@@ -0,0 +1,78 @@
+import { createClient } from "@/utils/supabase/server";
+import { deleteComment } from "./actions";
+
+export default async function AdminCommentsPage() {
+ const supabase = await createClient();
+
+ const { data: comments } = await supabase
+ .from("comments")
+ .select("id, name, email, comment, post_id, created_at")
+ .order("created_at", { ascending: false });
+
+ return (
+
+
Comments
+
+
+
+
+
+ |
+ Author
+ |
+
+ Comment
+ |
+
+ Post
+ |
+
+ Date
+ |
+
+ Actions
+ |
+
+
+
+ {comments?.map((comment) => (
+
+ |
+ {comment.name}
+ {comment.email}
+ |
+
+
+ {comment.comment}
+
+ |
+
+ {comment.post_id}
+ |
+
+ {new Date(comment.created_at).toLocaleDateString()}
+ |
+
+
+ |
+
+ ))}
+
+
+ {(!comments || comments.length === 0) && (
+
No comments found.
+ )}
+
+
+ );
+}
diff --git a/app/admin/(dashboard)/components/AdminSidebar.tsx b/app/admin/(dashboard)/components/AdminSidebar.tsx
new file mode 100644
index 0000000..c0b9465
--- /dev/null
+++ b/app/admin/(dashboard)/components/AdminSidebar.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname, useRouter } from "next/navigation";
+import { createClient } from "@/utils/supabase/client";
+
+interface AdminSidebarProps {
+ role: string;
+}
+
+const navItems = [
+ { label: "Dashboard", href: "/admin", minRole: "editor" },
+ { label: "Posts", href: "/admin/posts", minRole: "editor" },
+ { label: "Comments", href: "/admin/comments", minRole: "editor" },
+ { label: "Subscriptions", href: "/admin/subscriptions", minRole: "admin" },
+ { label: "Authors", href: "/admin/authors", minRole: "admin" },
+ { label: "Admin Users", href: "/admin/users", minRole: "super_admin" },
+];
+
+const roleHierarchy: Record = {
+ editor: 1,
+ admin: 2,
+ super_admin: 3,
+};
+
+export default function AdminSidebar({ role }: AdminSidebarProps) {
+ const pathname = usePathname();
+ const router = useRouter();
+ const supabase = createClient();
+
+ const handleLogout = async () => {
+ await supabase.auth.signOut();
+ router.push("/admin/login");
+ router.refresh();
+ };
+
+ const userLevel = roleHierarchy[role] || 0;
+
+ return (
+
+ );
+}
diff --git a/app/admin/(dashboard)/components/DeleteConfirmDialog.tsx b/app/admin/(dashboard)/components/DeleteConfirmDialog.tsx
new file mode 100644
index 0000000..348278f
--- /dev/null
+++ b/app/admin/(dashboard)/components/DeleteConfirmDialog.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { useState } from "react";
+
+interface DeleteConfirmDialogProps {
+ title: string;
+ message: string;
+ onConfirm: () => Promise;
+ children: React.ReactNode;
+}
+
+export default function DeleteConfirmDialog({
+ title,
+ message,
+ onConfirm,
+ children,
+}: DeleteConfirmDialogProps) {
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ const handleConfirm = async () => {
+ setLoading(true);
+ await onConfirm();
+ setLoading(false);
+ setOpen(false);
+ };
+
+ return (
+ <>
+ setOpen(true)}>{children}
+ {open && (
+
+
+
{title}
+
{message}
+
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/app/admin/(dashboard)/components/PostEditor.tsx b/app/admin/(dashboard)/components/PostEditor.tsx
new file mode 100644
index 0000000..1d955b3
--- /dev/null
+++ b/app/admin/(dashboard)/components/PostEditor.tsx
@@ -0,0 +1,202 @@
+"use client";
+
+import { useState } from "react";
+
+interface PostEditorProps {
+ action: (formData: FormData) => Promise;
+ initialData?: {
+ id: string;
+ title: string;
+ date: string;
+ excerpt: string;
+ content: string;
+ author: string;
+ tags: string[];
+ coverImage?: string | null;
+ published: boolean;
+ };
+ authors: { name: string }[];
+ isEdit?: boolean;
+}
+
+export default function PostEditor({
+ action,
+ initialData,
+ authors,
+ isEdit = false,
+}: PostEditorProps) {
+ const [content, setContent] = useState(initialData?.content || "");
+ const [published, setPublished] = useState(
+ initialData?.published ?? false
+ );
+
+ return (
+
+ );
+}
diff --git a/app/admin/(dashboard)/layout.tsx b/app/admin/(dashboard)/layout.tsx
new file mode 100644
index 0000000..54c51e4
--- /dev/null
+++ b/app/admin/(dashboard)/layout.tsx
@@ -0,0 +1,36 @@
+import { createClient } from "@/utils/supabase/server";
+import { redirect } from "next/navigation";
+import AdminSidebar from "./components/AdminSidebar";
+
+export default async function AdminDashboardLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const supabase = await createClient();
+
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ redirect("/admin/login");
+ }
+
+ const { data: adminUser } = await supabase
+ .from("admin_users")
+ .select("role")
+ .eq("user_id", user.id)
+ .single();
+
+ if (!adminUser) {
+ redirect("/admin/login");
+ }
+
+ return (
+
+ );
+}
diff --git a/app/admin/(dashboard)/page.tsx b/app/admin/(dashboard)/page.tsx
new file mode 100644
index 0000000..f79e946
--- /dev/null
+++ b/app/admin/(dashboard)/page.tsx
@@ -0,0 +1,131 @@
+import { createClient } from "@/utils/supabase/server";
+import Link from "next/link";
+
+export default async function AdminDashboardPage() {
+ const supabase = await createClient();
+
+ const [postsRes, commentsRes, subsRes, authorsRes] = await Promise.all([
+ supabase.from("posts").select("id", { count: "exact", head: true }),
+ supabase.from("comments").select("id", { count: "exact", head: true }),
+ supabase.from("subscriptions").select("id", { count: "exact", head: true }),
+ supabase.from("authors").select("name", { count: "exact", head: true }),
+ ]);
+
+ const stats = [
+ { label: "Posts", count: postsRes.count || 0, href: "/admin/posts" },
+ {
+ label: "Comments",
+ count: commentsRes.count || 0,
+ href: "/admin/comments",
+ },
+ {
+ label: "Subscribers",
+ count: subsRes.count || 0,
+ href: "/admin/subscriptions",
+ },
+ { label: "Authors", count: authorsRes.count || 0, href: "/admin/authors" },
+ ];
+
+ // Recent posts
+ const { data: recentPosts } = await supabase
+ .from("posts")
+ .select("id, title, date, published")
+ .order("created_at", { ascending: false })
+ .limit(5);
+
+ // Recent comments
+ const { data: recentComments } = await supabase
+ .from("comments")
+ .select("id, name, comment, post_id, created_at")
+ .order("created_at", { ascending: false })
+ .limit(5);
+
+ return (
+
+
Dashboard
+
+
+ {stats.map((stat) => (
+
+
{stat.label}
+
{stat.count}
+
+ ))}
+
+
+
+ {/* Recent Posts */}
+
+
+
Recent Posts
+
+ New Post
+
+
+ {recentPosts && recentPosts.length > 0 ? (
+
+ ) : (
+
No posts yet.
+ )}
+
+
+ {/* Recent Comments */}
+
+
+
Recent Comments
+
+ View All
+
+
+ {recentComments && recentComments.length > 0 ? (
+
+ ) : (
+
No comments yet.
+ )}
+
+
+
+ );
+}
diff --git a/app/admin/(dashboard)/posts/[id]/edit/page.tsx b/app/admin/(dashboard)/posts/[id]/edit/page.tsx
new file mode 100644
index 0000000..2d5762f
--- /dev/null
+++ b/app/admin/(dashboard)/posts/[id]/edit/page.tsx
@@ -0,0 +1,52 @@
+import { createClient } from "@/utils/supabase/server";
+import { notFound } from "next/navigation";
+import PostEditor from "../../../components/PostEditor";
+import { updatePost } from "../../actions";
+
+interface PageProps {
+ params: Promise<{ id: string }>;
+}
+
+export default async function EditPostPage({ params }: PageProps) {
+ const { id } = await params;
+ const supabase = await createClient();
+
+ const { data: post, error } = await supabase
+ .from("posts")
+ .select("*")
+ .eq("id", id)
+ .single();
+
+ if (error || !post) {
+ notFound();
+ }
+
+ const { data: authors } = await supabase
+ .from("authors")
+ .select("name")
+ .order("name");
+
+ return (
+
+ );
+}
diff --git a/app/admin/(dashboard)/posts/actions.ts b/app/admin/(dashboard)/posts/actions.ts
new file mode 100644
index 0000000..38ed390
--- /dev/null
+++ b/app/admin/(dashboard)/posts/actions.ts
@@ -0,0 +1,119 @@
+"use server";
+
+import { createClient } from "@/utils/supabase/server";
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { remark } from "remark";
+import html from "remark-html";
+
+export async function createPost(formData: FormData) {
+ const supabase = await createClient();
+
+ const id = formData.get("id") as string;
+ const title = formData.get("title") as string;
+ const date = formData.get("date") as string;
+ const excerpt = formData.get("excerpt") as string;
+ const content = formData.get("content") as string;
+ const author = formData.get("author") as string;
+ const tagsRaw = formData.get("tags") as string;
+ const coverImage = (formData.get("coverImage") as string) || null;
+ const published = formData.get("published") === "true";
+
+ const tags = tagsRaw
+ .split(",")
+ .map((t) => t.trim())
+ .filter(Boolean);
+
+ // Render markdown to HTML
+ const processedContent = await remark()
+ .use(html, { sanitize: false })
+ .process(content);
+ const contentHtml = processedContent.toString();
+
+ const { error } = await supabase.from("posts").insert({
+ id,
+ title,
+ date: new Date(date).toISOString(),
+ excerpt,
+ content,
+ content_html: contentHtml,
+ author,
+ tags,
+ cover_image: coverImage,
+ published,
+ });
+
+ if (error) {
+ throw new Error(`Failed to create post: ${error.message}`);
+ }
+
+ revalidatePath("/");
+ revalidatePath("/posts");
+ revalidatePath("/admin/posts");
+ redirect("/admin/posts");
+}
+
+export async function updatePost(formData: FormData) {
+ const supabase = await createClient();
+
+ const id = formData.get("id") as string;
+ const title = formData.get("title") as string;
+ const date = formData.get("date") as string;
+ const excerpt = formData.get("excerpt") as string;
+ const content = formData.get("content") as string;
+ const author = formData.get("author") as string;
+ const tagsRaw = formData.get("tags") as string;
+ const coverImage = (formData.get("coverImage") as string) || null;
+ const published = formData.get("published") === "true";
+
+ const tags = tagsRaw
+ .split(",")
+ .map((t) => t.trim())
+ .filter(Boolean);
+
+ // Render markdown to HTML
+ const processedContent = await remark()
+ .use(html, { sanitize: false })
+ .process(content);
+ const contentHtml = processedContent.toString();
+
+ const { error } = await supabase
+ .from("posts")
+ .update({
+ title,
+ date: new Date(date).toISOString(),
+ excerpt,
+ content,
+ content_html: contentHtml,
+ author,
+ tags,
+ cover_image: coverImage,
+ published,
+ })
+ .eq("id", id);
+
+ if (error) {
+ throw new Error(`Failed to update post: ${error.message}`);
+ }
+
+ revalidatePath("/");
+ revalidatePath("/posts");
+ revalidatePath(`/posts/${id}`);
+ revalidatePath("/admin/posts");
+ redirect("/admin/posts");
+}
+
+export async function deletePost(id: string) {
+ const supabase = await createClient();
+
+ const { error } = await supabase.from("posts").delete().eq("id", id);
+
+ if (error) {
+ throw new Error(`Failed to delete post: ${error.message}`);
+ }
+
+ revalidatePath("/");
+ revalidatePath("/posts");
+ revalidatePath("/admin/posts");
+ redirect("/admin/posts");
+}
diff --git a/app/admin/(dashboard)/posts/new/page.tsx b/app/admin/(dashboard)/posts/new/page.tsx
new file mode 100644
index 0000000..78a135b
--- /dev/null
+++ b/app/admin/(dashboard)/posts/new/page.tsx
@@ -0,0 +1,21 @@
+import { createClient } from "@/utils/supabase/server";
+import PostEditor from "../../components/PostEditor";
+import { createPost } from "../actions";
+
+export default async function NewPostPage() {
+ const supabase = await createClient();
+
+ const { data: authors } = await supabase
+ .from("authors")
+ .select("name")
+ .order("name");
+
+ return (
+
+ );
+}
diff --git a/app/admin/(dashboard)/posts/page.tsx b/app/admin/(dashboard)/posts/page.tsx
new file mode 100644
index 0000000..0ca7982
--- /dev/null
+++ b/app/admin/(dashboard)/posts/page.tsx
@@ -0,0 +1,99 @@
+import { createClient } from "@/utils/supabase/server";
+import Link from "next/link";
+import { deletePost } from "./actions";
+
+export default async function AdminPostsPage() {
+ const supabase = await createClient();
+
+ const { data: posts } = await supabase
+ .from("posts")
+ .select("id, title, date, author, published, tags")
+ .order("date", { ascending: false });
+
+ return (
+
+
+
Posts
+
+ New Post
+
+
+
+
+
+
+
+ |
+ Title
+ |
+
+ Author
+ |
+
+ Date
+ |
+
+ Status
+ |
+
+ Actions
+ |
+
+
+
+ {posts?.map((post) => (
+
+ |
+
+ {post.title}
+
+ {post.id}
+ |
+ {post.author} |
+
+ {new Date(post.date).toLocaleDateString()}
+ |
+
+ {post.published ? (
+
+ Published
+
+ ) : (
+
+ Draft
+
+ )}
+ |
+
+
+ Edit
+
+
+ |
+
+ ))}
+
+
+ {(!posts || posts.length === 0) && (
+
No posts found.
+ )}
+
+
+ );
+}
diff --git a/app/admin/(dashboard)/subscriptions/ExportButton.tsx b/app/admin/(dashboard)/subscriptions/ExportButton.tsx
new file mode 100644
index 0000000..59686e2
--- /dev/null
+++ b/app/admin/(dashboard)/subscriptions/ExportButton.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+interface ExportButtonProps {
+ subscriptions: { email: string; created_at: string }[];
+}
+
+export default function ExportButton({ subscriptions }: ExportButtonProps) {
+ const handleExport = () => {
+ const csv = [
+ "email,subscribed_date",
+ ...subscriptions.map(
+ (s) =>
+ `${s.email},${new Date(s.created_at).toISOString().split("T")[0]}`
+ ),
+ ].join("\n");
+
+ const blob = new Blob([csv], { type: "text/csv" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = "subscriptions.csv";
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ return (
+
+ );
+}
diff --git a/app/admin/(dashboard)/subscriptions/actions.ts b/app/admin/(dashboard)/subscriptions/actions.ts
new file mode 100644
index 0000000..c5efff7
--- /dev/null
+++ b/app/admin/(dashboard)/subscriptions/actions.ts
@@ -0,0 +1,19 @@
+"use server";
+
+import { createClient } from "@/utils/supabase/server";
+import { revalidatePath } from "next/cache";
+
+export async function deleteSubscription(id: number) {
+ const supabase = await createClient();
+
+ const { error } = await supabase
+ .from("subscriptions")
+ .delete()
+ .eq("id", id);
+
+ if (error) {
+ throw new Error(`Failed to delete subscription: ${error.message}`);
+ }
+
+ revalidatePath("/admin/subscriptions");
+}
diff --git a/app/admin/(dashboard)/subscriptions/page.tsx b/app/admin/(dashboard)/subscriptions/page.tsx
new file mode 100644
index 0000000..4c835f1
--- /dev/null
+++ b/app/admin/(dashboard)/subscriptions/page.tsx
@@ -0,0 +1,67 @@
+import { createClient } from "@/utils/supabase/server";
+import { deleteSubscription } from "./actions";
+import ExportButton from "./ExportButton";
+
+export default async function AdminSubscriptionsPage() {
+ const supabase = await createClient();
+
+ const { data: subscriptions } = await supabase
+ .from("subscriptions")
+ .select("id, email, created_at")
+ .order("created_at", { ascending: false });
+
+ return (
+
+
+
Subscriptions
+
+
+
+
+
+
+
+ |
+ Email
+ |
+
+ Subscribed
+ |
+
+ Actions
+ |
+
+
+
+ {subscriptions?.map((sub) => (
+
+ | {sub.email} |
+
+ {new Date(sub.created_at).toLocaleDateString()}
+ |
+
+
+ |
+
+ ))}
+
+
+ {(!subscriptions || subscriptions.length === 0) && (
+
+ No subscriptions found.
+
+ )}
+
+
+ );
+}
diff --git a/app/admin/(dashboard)/users/actions.ts b/app/admin/(dashboard)/users/actions.ts
new file mode 100644
index 0000000..25ce4a7
--- /dev/null
+++ b/app/admin/(dashboard)/users/actions.ts
@@ -0,0 +1,79 @@
+"use server";
+
+import { createClient } from "@/utils/supabase/server";
+import { revalidatePath } from "next/cache";
+
+export async function updateAdminRole(
+ adminId: string,
+ newRole: "super_admin" | "admin" | "editor"
+) {
+ const supabase = await createClient();
+
+ const { error } = await supabase
+ .from("admin_users")
+ .update({ role: newRole })
+ .eq("id", adminId);
+
+ if (error) {
+ throw new Error(`Failed to update role: ${error.message}`);
+ }
+
+ revalidatePath("/admin/users");
+}
+
+export async function removeAdmin(adminId: string) {
+ const supabase = await createClient();
+
+ const { error } = await supabase
+ .from("admin_users")
+ .delete()
+ .eq("id", adminId);
+
+ if (error) {
+ throw new Error(`Failed to remove admin: ${error.message}`);
+ }
+
+ revalidatePath("/admin/users");
+}
+
+export async function inviteAdmin(email: string, role: string) {
+ const supabase = await createClient();
+
+ // Check if user exists in auth
+ // Note: This requires admin API access. For now, we just add to admin_users
+ // The user must already have a Supabase Auth account.
+
+ // Look up user by email in admin_users to prevent duplicates
+ const { data: existing } = await supabase
+ .from("admin_users")
+ .select("id")
+ .eq("email", email)
+ .single();
+
+ if (existing) {
+ throw new Error("This email is already an admin.");
+ }
+
+ // We need the user's auth ID. Look them up via the admin_users approach:
+ // The admin must create the auth user first via Supabase dashboard,
+ // then add them here with their user_id.
+ // For a simpler flow, we'll insert with just email and let super_admin
+ // provide the user_id separately.
+
+ const userId = (await supabase.auth.getUser()).data.user?.id;
+ if (!userId) throw new Error("Not authenticated");
+
+ // This is a simplified version - in production you'd use the admin API
+ // to look up or invite the user
+ const { error } = await supabase.from("admin_users").insert({
+ email,
+ role,
+ user_id: "00000000-0000-0000-0000-000000000000", // placeholder - must be updated
+ });
+
+ if (error) {
+ throw new Error(`Failed to invite admin: ${error.message}`);
+ }
+
+ revalidatePath("/admin/users");
+}
diff --git a/app/admin/(dashboard)/users/page.tsx b/app/admin/(dashboard)/users/page.tsx
new file mode 100644
index 0000000..4293dec
--- /dev/null
+++ b/app/admin/(dashboard)/users/page.tsx
@@ -0,0 +1,198 @@
+import { createClient } from "@/utils/supabase/server";
+import { redirect } from "next/navigation";
+import { updateAdminRole, removeAdmin } from "./actions";
+
+export default async function AdminUsersPage() {
+ const supabase = await createClient();
+
+ // Verify current user is super_admin
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) redirect("/admin/login");
+
+ const { data: currentAdmin } = await supabase
+ .from("admin_users")
+ .select("role")
+ .eq("user_id", user.id)
+ .single();
+
+ if (!currentAdmin || currentAdmin.role !== "super_admin") {
+ redirect("/admin");
+ }
+
+ const { data: adminUsers } = await supabase
+ .from("admin_users")
+ .select("id, user_id, email, role, created_at")
+ .order("created_at");
+
+ return (
+
+
Admin Users
+
+
+
+
+
+ |
+ Email
+ |
+
+ Role
+ |
+
+ Added
+ |
+
+ Actions
+ |
+
+
+
+ {adminUsers?.map((adminUser) => (
+
+ | {adminUser.email} |
+
+
+ |
+
+ {new Date(adminUser.created_at).toLocaleDateString()}
+ |
+
+ {adminUser.user_id !== user.id && (
+
+ {(["editor", "admin", "super_admin"] as const)
+ .filter((r) => r !== adminUser.role)
+ .map((role) => (
+
+ ))}
+
+
+ )}
+ |
+
+ ))}
+
+
+ {(!adminUsers || adminUsers.length === 0) && (
+
+ No admin users found.
+
+ )}
+
+
+
+
Add Admin User
+
+ The user must first have a Supabase Auth account. Create one in the
+ Supabase dashboard, then add their email here.
+
+
+
+
+ );
+}
diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx
new file mode 100644
index 0000000..a7b5b40
--- /dev/null
+++ b/app/admin/layout.tsx
@@ -0,0 +1,9 @@
+export const dynamic = "force-dynamic";
+
+export default function AdminLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return <>{children}>;
+}
diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx
new file mode 100644
index 0000000..e6a31a5
--- /dev/null
+++ b/app/admin/login/page.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { createClient } from "@/utils/supabase/client";
+import { useRouter } from "next/navigation";
+import React, { useState } from "react";
+
+export default function AdminLoginPage() {
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const router = useRouter();
+ const supabase = createClient();
+
+ const handleLogin = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ setError(null);
+
+ const { error: signInError } = await supabase.auth.signInWithPassword({
+ email,
+ password,
+ });
+
+ if (signInError) {
+ setError(signInError.message);
+ setLoading(false);
+ return;
+ }
+
+ // Verify the user is an admin
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ setError("Authentication failed.");
+ setLoading(false);
+ return;
+ }
+
+ const { data: adminUser } = await supabase
+ .from("admin_users")
+ .select("role")
+ .eq("user_id", user.id)
+ .single();
+
+ if (!adminUser) {
+ await supabase.auth.signOut();
+ setError("You do not have admin access.");
+ setLoading(false);
+ return;
+ }
+
+ router.push("/admin");
+ router.refresh();
+ };
+
+ return (
+
+
+
Admin Login
+
+ Sign in to the Confessions of Grace admin panel.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/api/comments.ts b/app/api/comments.ts
deleted file mode 100644
index e97d39a..0000000
--- a/app/api/comments.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { supabase } from '@/utils/supabase';
-import { NextRequest, NextResponse } from 'next/server'; // Use next/server for Edge runtime
-
-export const runtime = 'edge';
-
-export default async function POST(req: NextRequest) {
- try {
- const { name, email, comment, postId } = await req.json();
-
- if (!name || !email || !comment || !postId) {
- return NextResponse.json({ message: 'All fields are required' }, { status: 400 });
- }
-
- // Insert data into the 'comments' table
- const { data, error } = await supabase
- .from('comments') // Replace 'comments' with your Supabase table name
- .insert([
- {
- name,
- email,
- comment,
- post_id: postId,
- created_at: new Date().toISOString(), // Supabase often uses ISO strings for timestamps
- },
- ]);
-
- if (error) {
- console.error('Error inserting comment:', error);
- return NextResponse.json({ message: 'Error submitting comment', error }, { status: 500 });
- }
-
- return NextResponse.json({ message: 'Comment submitted successfully', data }, { status: 201 });
- } catch (error) {
- console.error('Internal server error during POST:', error);
- // Catching and returning a 500 for unexpected errors
- return NextResponse.json({ message: 'Internal server error' }, { status: 500 });
- }
-}
-
-export async function GET(req: NextRequest) {
- try {
- // Get query parameters from the URL
- const { searchParams } = new URL(req.url);
- const postId = searchParams.get('postId');
-
- if (!postId) {
- return NextResponse.json({ message: 'Post ID is required' }, { status: 400 });
- }
-
- // Fetch comments for a specific postId, ordered by creation date descending
- const { data: comments, error } = await supabase
- .from('comments') // Replace 'comments' with your Supabase table name
- .select('*')
- .eq('post_id', postId) // Assuming your Supabase column for post ID is 'post_id'
- .order('created_at', { ascending: false }); // Assuming your timestamp column is 'created_at'
-
- if (error) {
- console.error('Error fetching comments:', error);
- return NextResponse.json({ message: 'Error fetching comments', error }, { status: 500 });
- }
-
- return NextResponse.json(comments, { status: 200 });
- } catch (error) {
- console.error('Internal server error during GET:', error);
- // Catching and returning a 500 for unexpected errors
- return NextResponse.json({ message: 'Internal server error' }, { status: 500 });
- }
-}
\ No newline at end of file
diff --git a/app/api/comments/route.ts b/app/api/comments/route.ts
new file mode 100644
index 0000000..8fc7ec2
--- /dev/null
+++ b/app/api/comments/route.ts
@@ -0,0 +1,81 @@
+import { createClient } from "@/utils/supabase/server";
+import { NextRequest, NextResponse } from "next/server";
+
+export async function POST(req: NextRequest) {
+ try {
+ const supabase = await createClient();
+ const { name, email, comment, postId } = await req.json();
+
+ if (!name || !email || !comment || !postId) {
+ return NextResponse.json(
+ { message: "All fields are required" },
+ { status: 400 }
+ );
+ }
+
+ const { data, error } = await supabase.from("comments").insert([
+ {
+ name,
+ email,
+ comment,
+ post_id: postId,
+ },
+ ]);
+
+ if (error) {
+ console.error("Error inserting comment:", error);
+ return NextResponse.json(
+ { message: "Error submitting comment", error },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json(
+ { message: "Comment submitted successfully", data },
+ { status: 201 }
+ );
+ } catch (error) {
+ console.error("Internal server error during POST:", error);
+ return NextResponse.json(
+ { message: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function GET(req: NextRequest) {
+ try {
+ const supabase = await createClient();
+ const { searchParams } = new URL(req.url);
+ const postId = searchParams.get("postId");
+
+ if (!postId) {
+ return NextResponse.json(
+ { message: "Post ID is required" },
+ { status: 400 }
+ );
+ }
+
+ const { data: comments, error } = await supabase
+ .from("comments")
+ .select("*")
+ .eq("post_id", postId)
+ .order("created_at", { ascending: false });
+
+ if (error) {
+ console.error("Error fetching comments:", error);
+ return NextResponse.json(
+ { message: "Error fetching comments", error },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json(comments, { status: 200 });
+ } catch (error) {
+ console.error("Internal server error during GET:", error);
+ return NextResponse.json(
+ { message: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/subscribe.ts b/app/api/subscribe.ts
deleted file mode 100644
index b66b892..0000000
--- a/app/api/subscribe.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { supabase } from '@/utils/supabase';
-import { NextRequest, NextResponse } from 'next/server'; // Use next/server for Edge runtime
-
-export const runtime = 'edge';
-
-export default async function POST(req: NextRequest) {
- // Only allow POST requests - this is handled by exporting the POST function
-
- const { email } = await req.json(); // Get body from Edge request
-
- // Check if email was provided
- if (!email) {
- return NextResponse.json({ message: 'Email is required.' }, { status: 400 });
- }
-
- // Validate email format
- const emailRegex = /\S+@\S+\.\S+/;
- if (!emailRegex.test(email)) {
- return NextResponse.json({ message: 'Invalid email address.' }, { status: 400 });
- }
-
- try {
- // Check if the email already exists in the 'subscriptions' table
- const { data: existingEmails, error: selectError } = await supabase
- .from('subscriptions') // Replace 'subscriptions' with your Supabase table name
- .select('email')
- .eq('email', email);
-
- if (selectError) {
- console.error('Error checking existing email:', selectError);
- return NextResponse.json({ message: 'An unexpected error occurred while checking email.' }, { status: 500 });
- }
-
- if (existingEmails && existingEmails.length > 0) {
- return NextResponse.json({ message: 'Email is already subscribed.' }, { status: 400 });
- }
-
- // Save email to the 'subscriptions' table
- const { data: insertedData, error: insertError } = await supabase
- .from('subscriptions') // Replace 'subscriptions' with your Supabase table name
- .insert([
- {
- email,
- created_at: new Date().toISOString(),
- },
- ]);
-
- if (insertError) {
- console.error('Failed to save subscription:', insertError);
- return NextResponse.json({ message: 'An unexpected error occurred while saving subscription.' }, { status: 500 });
- }
-
- // Respond with success
- return NextResponse.json({ message: 'Successfully subscribed!' }, { status: 200 });
-
- } catch (error) {
- // Log unexpected errors
- console.error('An unexpected server error occurred:', error);
- return NextResponse.json({ message: 'An unexpected error occurred.' }, { status: 500 });
- }
-}
\ No newline at end of file
diff --git a/app/api/subscribe/route.ts b/app/api/subscribe/route.ts
new file mode 100644
index 0000000..ef25312
--- /dev/null
+++ b/app/api/subscribe/route.ts
@@ -0,0 +1,70 @@
+import { createClient } from "@/utils/supabase/server";
+import { NextRequest, NextResponse } from "next/server";
+
+export async function POST(req: NextRequest) {
+ const supabase = await createClient();
+ const { email } = await req.json();
+
+ if (!email) {
+ return NextResponse.json(
+ { message: "Email is required." },
+ { status: 400 }
+ );
+ }
+
+ const emailRegex = /\S+@\S+\.\S+/;
+ if (!emailRegex.test(email)) {
+ return NextResponse.json(
+ { message: "Invalid email address." },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const { data: existingEmails, error: selectError } = await supabase
+ .from("subscriptions")
+ .select("email")
+ .eq("email", email);
+
+ if (selectError) {
+ console.error("Error checking existing email:", selectError);
+ return NextResponse.json(
+ { message: "An unexpected error occurred while checking email." },
+ { status: 500 }
+ );
+ }
+
+ if (existingEmails && existingEmails.length > 0) {
+ return NextResponse.json(
+ { message: "Email is already subscribed." },
+ { status: 400 }
+ );
+ }
+
+ const { error: insertError } = await supabase
+ .from("subscriptions")
+ .insert([{ email }]);
+
+ if (insertError) {
+ console.error("Failed to save subscription:", insertError);
+ return NextResponse.json(
+ {
+ message:
+ "An unexpected error occurred while saving subscription.",
+ },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json(
+ { message: "Successfully subscribed!" },
+ { status: 200 }
+ );
+ } catch (error) {
+ console.error("An unexpected server error occurred:", error);
+ return NextResponse.json(
+ { message: "An unexpected error occurred." },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/authors/[author]/AuthorProfile.tsx b/app/authors/[author]/AuthorProfile.tsx
index 908ab32..f96a8e4 100644
--- a/app/authors/[author]/AuthorProfile.tsx
+++ b/app/authors/[author]/AuthorProfile.tsx
@@ -1,7 +1,9 @@
'use client';
import React, { useEffect, useState } from 'react';
-import { supabase } from '@/utils/supabase';
+import { createClient } from '@/utils/supabase/client';
+
+const supabase = createClient();
import { PostMetadata } from '@/types';
interface AuthorProfile {
diff --git a/app/authors/[author]/page.tsx b/app/authors/[author]/page.tsx
index 4c04055..dda73d1 100644
--- a/app/authors/[author]/page.tsx
+++ b/app/authors/[author]/page.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import PostCard from '@/components/PostCard';
-import { getSortedPostsData, getPostsByAuthor } from '@/lib/markdown';
+import { getPostsByAuthor } from '@/lib/posts';
import { PostMetadata } from '@/types';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
@@ -10,19 +10,7 @@ interface PageProps {
params: Promise<{ author: string }>;
}
-export async function generateStaticParams() {
- try {
- const posts = getSortedPostsData();
- const authors = Array.from(new Set(posts.flatMap(post => post.author)));
-
- return authors.map((author) => ({
- author: author,
- }));
- } catch (error) {
- console.error('Failed to generate author paths during build:', error);
- return [];
- }
-}
+export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: PageProps): Promise {
const { author } = await params;
@@ -35,7 +23,7 @@ export async function generateMetadata({ params }: PageProps): Promise
}
async function getPostsByAuthorData(author: string): Promise {
- return getPostsByAuthor(author);
+ return await getPostsByAuthor(author);
}
export default async function AuthorPage({ params }: PageProps) {
diff --git a/app/authors/page.tsx b/app/authors/page.tsx
index 0a89dd9..3c49dbd 100644
--- a/app/authors/page.tsx
+++ b/app/authors/page.tsx
@@ -1,7 +1,9 @@
+export const dynamic = 'force-dynamic';
+
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
-import { supabase } from '@/utils/supabase';
+import { createClient } from '@/utils/supabase/server';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
@@ -25,6 +27,7 @@ export async function generateMetadata(): Promise {
async function getAuthors(): Promise {
try {
+ const supabase = await createClient();
const { data: authorsData, error } = await supabase
.from('authors')
.select('name, bio, x_link, fb_link, insta_link, pfp_link');
diff --git a/app/page.tsx b/app/page.tsx
index 28db261..e6ab1db 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,13 +1,15 @@
+export const dynamic = 'force-dynamic';
+
import React from 'react';
import PostCard from '@/components/PostCard';
import Sidebar from '@/components/Sidebar';
-import { getSortedPostsData } from '@/lib/markdown';
+import { getSortedPostsData } from '@/lib/posts';
import { PostMetadata } from '@/types';
import Image from 'next/image';
// This function runs on the server during build time
async function getHomeData() {
- const posts = getSortedPostsData();
+ const posts = await getSortedPostsData();
// Create tag data
const allTags = posts.flatMap(post => post.tags);
diff --git a/app/posts/[id]/page.tsx b/app/posts/[id]/page.tsx
index b1039e3..0b01800 100644
--- a/app/posts/[id]/page.tsx
+++ b/app/posts/[id]/page.tsx
@@ -1,5 +1,7 @@
+export const dynamic = "force-dynamic";
+
import ShareButtons from "@/components/ShareButtons";
-import { getPostData, getSortedPostsData } from "@/lib/markdown";
+import { getPostData, getSortedPostsData } from "@/lib/posts";
import { PostData, PostMetadata } from "@/types";
import { format } from "date-fns";
import Image from "next/image";
@@ -34,7 +36,7 @@ async function getPostAndMorePosts(
id: string,
): Promise<{ post: PostData; morePosts: PostMetadata[] }> {
const post = await getPostData(id);
- const allPosts = getSortedPostsData();
+ const allPosts = await getSortedPostsData();
// Filter out the current post and get a few related posts (by tags)
const otherPosts = allPosts.filter((p) => p.id !== post.id);
diff --git a/app/posts/page.tsx b/app/posts/page.tsx
index 90c1df4..fd1577c 100644
--- a/app/posts/page.tsx
+++ b/app/posts/page.tsx
@@ -1,7 +1,9 @@
+export const dynamic = 'force-dynamic';
+
import React from 'react';
import Link from 'next/link';
import { format } from 'date-fns';
-import { getSortedPostsData } from '@/lib/markdown';
+import { getSortedPostsData } from '@/lib/posts';
import { PostMetadata } from '@/types';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
@@ -16,7 +18,7 @@ export async function generateMetadata(): Promise {
}
async function getPostsAndYears(): Promise<{ posts: PostMetadata[]; years: number[] }> {
- const posts = getSortedPostsData();
+ const posts = await getSortedPostsData();
// Extract unique years from post dates
const years = Array.from(new Set(
diff --git a/app/search/page.tsx b/app/search/page.tsx
index e580b6b..2e65dba 100644
--- a/app/search/page.tsx
+++ b/app/search/page.tsx
@@ -1,5 +1,7 @@
+export const dynamic = 'force-dynamic';
+
import React, {Suspense} from 'react';
-import { getSortedPostsData } from '@/lib/markdown';
+import { getSortedPostsData } from '@/lib/posts';
import { PostMetadata } from '@/types';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
diff --git a/app/tags/[tag]/page.tsx b/app/tags/[tag]/page.tsx
index 75744b2..cbd9b32 100644
--- a/app/tags/[tag]/page.tsx
+++ b/app/tags/[tag]/page.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import PostCard from '@/components/PostCard';
-import { getSortedPostsData, getPostsByTag } from '@/lib/markdown';
+import { getPostsByTag } from '@/lib/posts';
import { PostMetadata } from '@/types';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
@@ -9,21 +9,7 @@ interface PageProps {
params: Promise<{ tag: string }>;
}
-export async function generateStaticParams() {
- try {
- const posts = getSortedPostsData();
- const tags = Array.from(new Set(posts.flatMap(post => post.tags)));
-
- return tags
- .filter(tag => tag && tag.trim() !== '') // Filter out empty or undefined tags
- .map((tag) => ({
- tag: encodeURIComponent(tag),
- }));
- } catch (error) {
- console.error('Error generating static params for tags:', error);
- return [];
- }
-}
+export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: PageProps): Promise {
const { tag } = await params;
@@ -38,7 +24,7 @@ export async function generateMetadata({ params }: PageProps): Promise
async function getPostsByTagData(tag: string): Promise {
const decodedTag = decodeURIComponent(tag);
- return getPostsByTag(decodedTag);
+ return await getPostsByTag(decodedTag);
}
export default async function TagPage({ params }: PageProps) {
diff --git a/app/tags/page.tsx b/app/tags/page.tsx
index 1d94c0e..bd84739 100644
--- a/app/tags/page.tsx
+++ b/app/tags/page.tsx
@@ -1,7 +1,8 @@
+export const dynamic = 'force-dynamic';
import React from 'react';
import Link from 'next/link';
-import { getSortedPostsData } from '@/lib/markdown';
+import { getSortedPostsData } from '@/lib/posts';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
@@ -16,7 +17,7 @@ export async function generateMetadata(): Promise {
async function getAllTags(): Promise<{ tag: string; count: number }[]> {
try {
- const posts = getSortedPostsData();
+ const posts = await getSortedPostsData();
const tagCount: { [key: string]: number } = {};
// Count occurrences of each tag
diff --git a/components/CommentSection.tsx b/components/CommentSection.tsx
index 8fdc8ee..eaca672 100644
--- a/components/CommentSection.tsx
+++ b/components/CommentSection.tsx
@@ -1,6 +1,8 @@
"use client"
-import { supabase } from '@/utils/supabase';
+import { createClient } from '@/utils/supabase/client';
+
+const supabase = createClient();
import React, { useState, useEffect } from 'react';
interface CommentFormProps {
diff --git a/lib/markdown.ts b/lib/markdown.ts
deleted file mode 100644
index 62569ba..0000000
--- a/lib/markdown.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-
-import fs from 'fs';
-import path from 'path';
-import matter from 'gray-matter';
-import { remark } from 'remark';
-import html from 'remark-html';
-import { PostData, PostMetadata } from '@/types';
-
-// Make sure this is only used on the server side
-const postsDirectory = path.join(process.cwd(), 'data/posts');
-
-export function getSortedPostsData(): PostMetadata[] {
- // Ensure we only run this on the server
- if (typeof window === 'undefined') {
- try {
- // Get file names under /posts
- const fileNames = fs.readdirSync(postsDirectory);
- const allPostsData = fileNames
- .filter(fileName => fileName.endsWith('.md')) // Only process .md files
- .map((fileName) => {
- // Remove ".md" from file name to get id
- const id = fileName.replace(/\.md$/, '');
-
- // Skip if id is empty or undefined
- if (!id || id === 'undefined') {
- console.warn(`Skipping invalid filename: ${fileName}`);
- return null;
- }
-
- // Read markdown file as string
- const fullPath = path.join(postsDirectory, fileName);
- const fileContents = fs.readFileSync(fullPath, 'utf8');
-
- // Use gray-matter to parse the post metadata section
- const matterResult = matter(fileContents);
-
- // Combine the data with the id
- return {
- id,
- title: matterResult.data.title || '',
- date: matterResult.data.date || '',
- excerpt: matterResult.data.excerpt || '',
- author: matterResult.data.author || '',
- tags: matterResult.data.tags || [],
- coverImage: matterResult.data.coverImage || undefined,
- } as PostMetadata;
- })
- .filter((post): post is PostMetadata => post !== null); // Filter out null values
-
- // Sort posts by date
- return allPostsData.sort((a, b) => {
- if (a.date < b.date) {
- return 1;
- } else {
- return -1;
- }
- });
- } catch (error) {
- console.error('Error reading posts directory:', error);
- return [];
- }
- }
-
- // Return empty array if running on client side
- return [];
-}
-
-export function getAllPostIds() {
- // Ensure we only run this on the server
- if (typeof window === 'undefined') {
- try {
- const fileNames = fs.readdirSync(postsDirectory);
-
- return fileNames
- .filter(fileName => fileName.endsWith('.md'))
- .map((fileName) => {
- const id = fileName.replace(/\.md$/, '');
- if (!id || id === 'undefined') {
- return null;
- }
- return {
- params: {
- id,
- },
- };
- })
- .filter((item): item is { params: { id: string } } => item !== null);
- } catch (error) {
- console.error('Error reading posts directory:', error);
- return [];
- }
- }
-
- // Return empty array if running on client side
- return [];
-}
-
-export async function getPostData(id: string): Promise {
- // Ensure we only run this on the server
- if (typeof window === 'undefined') {
- try {
- // Validate id
- if (!id || id === 'undefined') {
- throw new Error(`Invalid post id: ${id}`);
- }
-
- const fullPath = path.join(postsDirectory, `${id}.md`);
-
- // Check if file exists
- if (!fs.existsSync(fullPath)) {
- throw new Error(`Post file not found: ${fullPath}`);
- }
-
- const fileContents = fs.readFileSync(fullPath, 'utf8');
-
- // Use gray-matter to parse the post metadata section
- const matterResult = matter(fileContents);
-
- // Use remark to convert markdown into HTML string
- const processedContent = await remark()
- .use(html, { sanitize: false })
- .process(matterResult.content);
- const contentHtml = processedContent.toString();
-
- // Combine the data with the id and contentHtml
- return {
- id,
- content: contentHtml,
- title: matterResult.data.title || '',
- date: matterResult.data.date || '',
- excerpt: matterResult.data.excerpt || '',
- author: matterResult.data.author || '',
- tags: matterResult.data.tags || [],
- coverImage: matterResult.data.coverImage || undefined,
- };
- } catch (error) {
- console.error(`Error loading post ${id}:`, error);
- throw error;
- }
- }
-
- // Return empty object if running on client side (should never happen in practice)
- return {
- id: '',
- content: '',
- title: '',
- date: '',
- excerpt: '',
- author: '',
- tags: [],
- };
-}
-
-export function getPostsByTag(tag: string): PostMetadata[] {
- const allPosts = getSortedPostsData();
- return allPosts.filter(post => post.tags.includes(tag));
-}
-
-export function getPostsByAuthor(author: string): PostMetadata[] {
- const allPosts = getSortedPostsData();
- return allPosts.filter(post => post.author.includes(author));
-}
\ No newline at end of file
diff --git a/lib/posts.ts b/lib/posts.ts
new file mode 100644
index 0000000..482b3b8
--- /dev/null
+++ b/lib/posts.ts
@@ -0,0 +1,125 @@
+import { createClient } from "@/utils/supabase/server";
+import { PostData, PostMetadata } from "@/types";
+
+export async function getSortedPostsData(): Promise {
+ const supabase = await createClient();
+
+ const { data, error } = await supabase
+ .from("posts")
+ .select("id, title, date, excerpt, author, tags, cover_image")
+ .eq("published", true)
+ .order("date", { ascending: false });
+
+ if (error) {
+ console.error("Error fetching posts:", error);
+ return [];
+ }
+
+ return (data || []).map((post) => ({
+ id: post.id,
+ title: post.title,
+ date: post.date,
+ excerpt: post.excerpt,
+ author: post.author,
+ tags: post.tags || [],
+ coverImage: post.cover_image || undefined,
+ }));
+}
+
+export async function getAllPostIds(): Promise<{ params: { id: string } }[]> {
+ const supabase = await createClient();
+
+ const { data, error } = await supabase
+ .from("posts")
+ .select("id")
+ .eq("published", true);
+
+ if (error) {
+ console.error("Error fetching post IDs:", error);
+ return [];
+ }
+
+ return (data || []).map((post) => ({
+ params: { id: post.id },
+ }));
+}
+
+export async function getPostData(id: string): Promise {
+ const supabase = await createClient();
+
+ const { data, error } = await supabase
+ .from("posts")
+ .select("id, title, date, excerpt, content_html, author, tags, cover_image")
+ .eq("id", id)
+ .single();
+
+ if (error || !data) {
+ console.error(`Error fetching post ${id}:`, error);
+ throw new Error(`Post not found: ${id}`);
+ }
+
+ return {
+ id: data.id,
+ title: data.title,
+ date: data.date,
+ excerpt: data.excerpt,
+ content: data.content_html,
+ author: data.author,
+ tags: data.tags || [],
+ coverImage: data.cover_image || undefined,
+ };
+}
+
+export async function getPostsByTag(tag: string): Promise {
+ const supabase = await createClient();
+
+ const { data, error } = await supabase
+ .from("posts")
+ .select("id, title, date, excerpt, author, tags, cover_image")
+ .eq("published", true)
+ .contains("tags", [tag])
+ .order("date", { ascending: false });
+
+ if (error) {
+ console.error("Error fetching posts by tag:", error);
+ return [];
+ }
+
+ return (data || []).map((post) => ({
+ id: post.id,
+ title: post.title,
+ date: post.date,
+ excerpt: post.excerpt,
+ author: post.author,
+ tags: post.tags || [],
+ coverImage: post.cover_image || undefined,
+ }));
+}
+
+export async function getPostsByAuthor(
+ author: string
+): Promise {
+ const supabase = await createClient();
+
+ const { data, error } = await supabase
+ .from("posts")
+ .select("id, title, date, excerpt, author, tags, cover_image")
+ .eq("published", true)
+ .eq("author", author)
+ .order("date", { ascending: false });
+
+ if (error) {
+ console.error("Error fetching posts by author:", error);
+ return [];
+ }
+
+ return (data || []).map((post) => ({
+ id: post.id,
+ title: post.title,
+ date: post.date,
+ excerpt: post.excerpt,
+ author: post.author,
+ tags: post.tags || [],
+ coverImage: post.cover_image || undefined,
+ }));
+}
diff --git a/package-lock.json b/package-lock.json
index 7cc7fd3..35d54ea 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "confessions-of-grace",
"version": "0.1.0",
"dependencies": {
+ "@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.55.0",
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
@@ -19,8 +20,7 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"remark": "^15.0.1",
- "remark-html": "^16.0.1",
- "supabase": "^2.34.3"
+ "remark-html": "^16.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -28,6 +28,8 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "dotenv": "^17.3.1",
+ "supabase": "^2.76.11",
"tailwindcss": "^4.1.12",
"typescript": "^5",
"wrangler": "^4.31.0"
@@ -1103,6 +1105,7 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
+ "dev": true,
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
@@ -1345,77 +1348,95 @@
"license": "CC0-1.0"
},
"node_modules/@supabase/auth-js": {
- "version": "2.71.1",
- "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz",
- "integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==",
+ "version": "2.97.0",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.97.0.tgz",
+ "integrity": "sha512-2Og/1lqp+AIavr8qS2X04aSl8RBY06y4LrtIAGxat06XoXYiDxKNQMQzWDAKm1EyZFZVRNH48DO5YvIZ7la5fQ==",
"license": "MIT",
"dependencies": {
- "@supabase/node-fetch": "^2.6.14"
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
- "version": "2.4.5",
- "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz",
- "integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==",
+ "version": "2.97.0",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.97.0.tgz",
+ "integrity": "sha512-fSaA0ZeBUS9hMgpGZt5shIZvfs3Mvx2ZdajQT4kv/whubqDBAp3GU5W8iIXy21MRvKmO2NpAj8/Q6y+ZkZyF/w==",
"license": "MIT",
"dependencies": {
- "@supabase/node-fetch": "^2.6.14"
- }
- },
- "node_modules/@supabase/node-fetch": {
- "version": "2.6.15",
- "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
- "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
- "license": "MIT",
- "dependencies": {
- "whatwg-url": "^5.0.0"
+ "tslib": "2.8.1"
},
"engines": {
- "node": "4.x || >=6.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
- "version": "1.19.4",
- "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz",
- "integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==",
+ "version": "2.97.0",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.97.0.tgz",
+ "integrity": "sha512-g4Ps0eaxZZurvfv/KGoo2XPZNpyNtjth9aW8eho9LZWM0bUuBtxPZw3ZQ6ERSpEGogshR+XNgwlSPIwcuHCNww==",
"license": "MIT",
"dependencies": {
- "@supabase/node-fetch": "^2.6.14"
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
- "version": "2.15.1",
- "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.1.tgz",
- "integrity": "sha512-edRFa2IrQw50kNntvUyS38hsL7t2d/psah6om6aNTLLcWem0R6bOUq7sk7DsGeSlNfuwEwWn57FdYSva6VddYw==",
+ "version": "2.97.0",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.97.0.tgz",
+ "integrity": "sha512-37Jw0NLaFP0CZd7qCan97D1zWutPrTSpgWxAw6Yok59JZoxp4IIKMrPeftJ3LZHmf+ILQOPy3i0pRDHM9FY36Q==",
"license": "MIT",
"dependencies": {
- "@supabase/node-fetch": "^2.6.13",
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
+ "tslib": "2.8.1",
"ws": "^8.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/ssr": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.8.0.tgz",
+ "integrity": "sha512-/PKk8kNFSs8QvvJ2vOww1mF5/c5W8y42duYtXvkOSe+yZKRgTTZywYG2l41pjhNomqESZCpZtXuWmYjFRMV+dw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.2"
+ },
+ "peerDependencies": {
+ "@supabase/supabase-js": "^2.76.1"
}
},
"node_modules/@supabase/storage-js": {
- "version": "2.11.0",
- "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.11.0.tgz",
- "integrity": "sha512-Y+kx/wDgd4oasAgoAq0bsbQojwQ+ejIif8uczZ9qufRHWFLMU5cODT+ApHsSrDufqUcVKt+eyxtOXSkeh2v9ww==",
+ "version": "2.97.0",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.97.0.tgz",
+ "integrity": "sha512-9f6NniSBfuMxOWKwEFb+RjJzkfMdJUwv9oHuFJKfe/5VJR8cd90qw68m6Hn0ImGtwG37TUO+QHtoOechxRJ1Yg==",
"license": "MIT",
"dependencies": {
- "@supabase/node-fetch": "^2.6.14"
+ "iceberg-js": "^0.8.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
- "version": "2.55.0",
- "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.55.0.tgz",
- "integrity": "sha512-Y1uV4nEMjQV1x83DGn7+Z9LOisVVRlY1geSARrUHbXWgbyKLZ6/08dvc0Us1r6AJ4tcKpwpCZWG9yDQYo1JgHg==",
+ "version": "2.97.0",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.97.0.tgz",
+ "integrity": "sha512-kTD91rZNO4LvRUHv4x3/4hNmsEd2ofkYhuba2VMUPRVef1RCmnHtm7rIws38Fg0yQnOSZOplQzafn0GSiy6GVg==",
"license": "MIT",
"dependencies": {
- "@supabase/auth-js": "2.71.1",
- "@supabase/functions-js": "2.4.5",
- "@supabase/node-fetch": "2.6.15",
- "@supabase/postgrest-js": "1.19.4",
- "@supabase/realtime-js": "2.15.1",
- "@supabase/storage-js": "^2.10.4"
+ "@supabase/auth-js": "2.97.0",
+ "@supabase/functions-js": "2.97.0",
+ "@supabase/postgrest-js": "2.97.0",
+ "@supabase/realtime-js": "2.97.0",
+ "@supabase/storage-js": "2.97.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
}
},
"node_modules/@swc/helpers": {
@@ -1762,9 +1783,9 @@
}
},
"node_modules/@types/phoenix": {
- "version": "1.6.6",
- "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
- "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
+ "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
"license": "MIT"
},
"node_modules/@types/react": {
@@ -1885,6 +1906,7 @@
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -1959,6 +1981,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz",
"integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==",
+ "dev": true,
"license": "ISC",
"dependencies": {
"cmd-shim": "^8.0.0",
@@ -2074,6 +2097,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
+ "dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
@@ -2089,6 +2113,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz",
"integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==",
+ "dev": true,
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
@@ -2108,7 +2133,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -2142,6 +2166,7 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -2219,6 +2244,19 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/dotenv": {
+ "version": "17.3.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
+ "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.203",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.203.tgz",
@@ -2335,6 +2373,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
+ "dev": true,
"funding": [
{
"type": "github",
@@ -2358,6 +2397,7 @@
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
@@ -2481,6 +2521,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
@@ -2490,10 +2531,20 @@
"node": ">= 14"
}
},
+ "node_modules/iceberg-js": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.19"
@@ -3424,6 +3475,7 @@
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -3433,6 +3485,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
@@ -3551,6 +3604,7 @@
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
+ "dev": true,
"funding": [
{
"type": "github",
@@ -3570,6 +3624,7 @@
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
@@ -3603,6 +3658,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz",
"integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==",
+ "dev": true,
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
@@ -3680,6 +3736,7 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
"integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==",
+ "dev": true,
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
@@ -3720,6 +3777,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz",
"integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==",
+ "dev": true,
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
@@ -3870,6 +3928,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
@@ -3950,16 +4009,17 @@
}
},
"node_modules/supabase": {
- "version": "2.75.0",
- "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.75.0.tgz",
- "integrity": "sha512-Xaai8Wp7F03OMkOWSyS1OMMdk5DxkauJxcMMesj4mO6jDGEZpWBwgo/gIUb4hsckexmr/RXYdpDpTbUIMXahbw==",
+ "version": "2.76.11",
+ "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.76.11.tgz",
+ "integrity": "sha512-dr+YndEjJe54CBHayvN9B+Hhx9SiuB4h4N/SHBWsplgEkO6ubQz/Gq0/O+iBCdjSkeEjk/JLi4tc+A5yLgCjXw==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bin-links": "^6.0.0",
"https-proxy-agent": "^7.0.2",
"node-fetch": "^3.3.2",
- "tar": "7.5.7"
+ "tar": "7.5.9"
},
"bin": {
"supabase": "bin/supabase"
@@ -3999,9 +4059,10 @@
}
},
"node_modules/tar": {
- "version": "7.5.7",
- "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
- "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
+ "version": "7.5.9",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
+ "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
+ "dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
@@ -4014,12 +4075,6 @@
"node": ">=18"
}
},
- "node_modules/tr46": {
- "version": "0.0.3",
- "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
- "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
- "license": "MIT"
- },
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -4242,27 +4297,12 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
- "node_modules/webidl-conversions": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
- "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
- "license": "BSD-2-Clause"
- },
- "node_modules/whatwg-url": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
- "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
- "license": "MIT",
- "dependencies": {
- "tr46": "~0.0.3",
- "webidl-conversions": "^3.0.0"
- }
- },
"node_modules/workerd": {
"version": "1.20260128.0",
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260128.0.tgz",
@@ -4323,6 +4363,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz",
"integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==",
+ "dev": true,
"license": "ISC",
"dependencies": {
"imurmurhash": "^0.1.4",
@@ -4333,9 +4374,9 @@
}
},
"node_modules/ws": {
- "version": "8.18.3",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
- "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -4357,6 +4398,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
+ "dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
diff --git a/package.json b/package.json
index 0b53f6e..de18d61 100644
--- a/package.json
+++ b/package.json
@@ -6,9 +6,11 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
- "lint": "next lint"
+ "lint": "next lint",
+ "migrate-posts": "npx tsx scripts/migrate-posts.ts"
},
"dependencies": {
+ "@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.55.0",
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
@@ -20,8 +22,7 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"remark": "^15.0.1",
- "remark-html": "^16.0.1",
- "supabase": "^2.34.3"
+ "remark-html": "^16.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -29,6 +30,8 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "dotenv": "^17.3.1",
+ "supabase": "^2.76.11",
"tailwindcss": "^4.1.12",
"typescript": "^5",
"wrangler": "^4.31.0"
diff --git a/proxy.ts b/proxy.ts
new file mode 100644
index 0000000..2c70531
--- /dev/null
+++ b/proxy.ts
@@ -0,0 +1,19 @@
+import { type NextRequest } from "next/server";
+import { updateSession } from "@/utils/supabase/middleware";
+
+export async function proxy(request: NextRequest) {
+ return await updateSession(request);
+}
+
+export const config = {
+ matcher: [
+ /*
+ * Match all request paths except for the ones starting with:
+ * - _next/static (static files)
+ * - _next/image (image optimization files)
+ * - favicon.ico (favicon file)
+ * - public folder assets
+ */
+ "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
+ ],
+};
diff --git a/scripts/migrate-posts.ts b/scripts/migrate-posts.ts
new file mode 100644
index 0000000..166642d
--- /dev/null
+++ b/scripts/migrate-posts.ts
@@ -0,0 +1,78 @@
+import dotenv from "dotenv";
+dotenv.config({ path: ".env.local" });
+import fs from "fs";
+import path from "path";
+import matter from "gray-matter";
+import { remark } from "remark";
+import html from "remark-html";
+import { createClient } from "@supabase/supabase-js";
+
+const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
+const supabaseKey =
+ process.env.SUPABASE_SECRET_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
+
+if (!supabaseUrl || !supabaseKey) {
+ console.error(
+ "Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SECRET_KEY in .env.local"
+ );
+ process.exit(1);
+}
+
+const supabase = createClient(supabaseUrl, supabaseKey);
+
+const postsDirectory = path.join(process.cwd(), "data/posts");
+
+async function migratePost(fileName: string) {
+ const id = fileName.replace(/\.md$/, "");
+ const fullPath = path.join(postsDirectory, fileName);
+ const fileContents = fs.readFileSync(fullPath, "utf8");
+
+ const matterResult = matter(fileContents);
+
+ // Render markdown to HTML
+ const processedContent = await remark()
+ .use(html, { sanitize: false })
+ .process(matterResult.content);
+ const contentHtml = processedContent.toString();
+
+ const post = {
+ id,
+ title: matterResult.data.title || "",
+ date: new Date(matterResult.data.date).toISOString(),
+ excerpt: matterResult.data.excerpt || "",
+ content: matterResult.content, // raw markdown
+ content_html: contentHtml,
+ author: matterResult.data.author || "",
+ tags: matterResult.data.tags || [],
+ cover_image: matterResult.data.coverImage || null,
+ published: true,
+ };
+
+ const { error } = await supabase.from("posts").upsert(post, {
+ onConflict: "id",
+ });
+
+ if (error) {
+ console.error(`Failed to upsert post "${id}":`, error.message);
+ } else {
+ console.log(`Migrated: ${id}`);
+ }
+}
+
+async function main() {
+ console.log("Starting post migration...\n");
+
+ const fileNames = fs
+ .readdirSync(postsDirectory)
+ .filter((f) => f.endsWith(".md"));
+
+ console.log(`Found ${fileNames.length} markdown files.\n`);
+
+ for (const fileName of fileNames) {
+ await migratePost(fileName);
+ }
+
+ console.log("\nMigration complete!");
+}
+
+main().catch(console.error);
diff --git a/supabase/.gitignore b/supabase/.gitignore
new file mode 100644
index 0000000..ad9264f
--- /dev/null
+++ b/supabase/.gitignore
@@ -0,0 +1,8 @@
+# Supabase
+.branches
+.temp
+
+# dotenvx
+.env.keys
+.env.local
+.env.*.local
diff --git a/supabase/config.toml b/supabase/config.toml
new file mode 100644
index 0000000..63d256e
--- /dev/null
+++ b/supabase/config.toml
@@ -0,0 +1,388 @@
+# For detailed configuration reference documentation, visit:
+# https://supabase.com/docs/guides/local-development/cli/config
+# A string used to distinguish different Supabase projects on the same host. Defaults to the
+# working directory name when running `supabase init`.
+project_id = "confessions-of-grace"
+
+[api]
+enabled = true
+# Port to use for the API URL.
+port = 54321
+# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
+# endpoints. `public` and `graphql_public` schemas are included by default.
+schemas = ["public", "graphql_public"]
+# Extra schemas to add to the search_path of every request.
+extra_search_path = ["public", "extensions"]
+# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
+# for accidental or malicious requests.
+max_rows = 1000
+
+[api.tls]
+# Enable HTTPS endpoints locally using a self-signed certificate.
+enabled = false
+# Paths to self-signed certificate pair.
+# cert_path = "../certs/my-cert.pem"
+# key_path = "../certs/my-key.pem"
+
+[db]
+# Port to use for the local database URL.
+port = 54322
+# Port used by db diff command to initialize the shadow database.
+shadow_port = 54320
+# Maximum amount of time to wait for health check when starting the local database.
+health_timeout = "2m"
+# The database major version to use. This has to be the same as your remote database's. Run `SHOW
+# server_version;` on the remote database to check.
+major_version = 17
+
+[db.pooler]
+enabled = false
+# Port to use for the local connection pooler.
+port = 54329
+# Specifies when a server connection can be reused by other clients.
+# Configure one of the supported pooler modes: `transaction`, `session`.
+pool_mode = "transaction"
+# How many server connections to allow per user/database pair.
+default_pool_size = 20
+# Maximum number of client connections allowed.
+max_client_conn = 100
+
+# [db.vault]
+# secret_key = "env(SECRET_VALUE)"
+
+[db.migrations]
+# If disabled, migrations will be skipped during a db push or reset.
+enabled = true
+# Specifies an ordered list of schema files that describe your database.
+# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
+schema_paths = []
+
+[db.seed]
+# If enabled, seeds the database after migrations during a db reset.
+enabled = true
+# Specifies an ordered list of seed files to load during db reset.
+# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
+sql_paths = ["./seed.sql"]
+
+[db.network_restrictions]
+# Enable management of network restrictions.
+enabled = false
+# List of IPv4 CIDR blocks allowed to connect to the database.
+# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
+allowed_cidrs = ["0.0.0.0/0"]
+# List of IPv6 CIDR blocks allowed to connect to the database.
+# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
+allowed_cidrs_v6 = ["::/0"]
+
+# Uncomment to reject non-secure connections to the database.
+# [db.ssl_enforcement]
+# enabled = true
+
+[realtime]
+enabled = true
+# Bind realtime via either IPv4 or IPv6. (default: IPv4)
+# ip_version = "IPv6"
+# The maximum length in bytes of HTTP request headers. (default: 4096)
+# max_header_length = 4096
+
+[studio]
+enabled = true
+# Port to use for Supabase Studio.
+port = 54323
+# External URL of the API server that frontend connects to.
+api_url = "http://127.0.0.1"
+# OpenAI API Key to use for Supabase AI in the Supabase Studio.
+openai_api_key = "env(OPENAI_API_KEY)"
+
+# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
+# are monitored, and you can view the emails that would have been sent from the web interface.
+[inbucket]
+enabled = true
+# Port to use for the email testing server web interface.
+port = 54324
+# Uncomment to expose additional ports for testing user applications that send emails.
+# smtp_port = 54325
+# pop3_port = 54326
+# admin_email = "admin@email.com"
+# sender_name = "Admin"
+
+[storage]
+enabled = true
+# The maximum file size allowed (e.g. "5MB", "500KB").
+file_size_limit = "50MiB"
+
+# Uncomment to configure local storage buckets
+# [storage.buckets.images]
+# public = false
+# file_size_limit = "50MiB"
+# allowed_mime_types = ["image/png", "image/jpeg"]
+# objects_path = "./images"
+
+# Allow connections via S3 compatible clients
+[storage.s3_protocol]
+enabled = true
+
+# Image transformation API is available to Supabase Pro plan.
+# [storage.image_transformation]
+# enabled = true
+
+# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
+# This feature is only available on the hosted platform.
+[storage.analytics]
+enabled = false
+max_namespaces = 5
+max_tables = 10
+max_catalogs = 2
+
+# Analytics Buckets is available to Supabase Pro plan.
+# [storage.analytics.buckets.my-warehouse]
+
+# Store vector embeddings in S3 for large and durable datasets
+# This feature is only available on the hosted platform.
+[storage.vector]
+enabled = false
+max_buckets = 10
+max_indexes = 5
+
+# Vector Buckets is available to Supabase Pro plan.
+# [storage.vector.buckets.documents-openai]
+
+[auth]
+enabled = true
+# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
+# in emails.
+site_url = "http://127.0.0.1:3000"
+# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
+additional_redirect_urls = ["https://127.0.0.1:3000"]
+# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
+jwt_expiry = 3600
+# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1).
+# jwt_issuer = ""
+# Path to JWT signing key. DO NOT commit your signing keys file to git.
+# signing_keys_path = "./signing_keys.json"
+# If disabled, the refresh token will never expire.
+enable_refresh_token_rotation = true
+# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
+# Requires enable_refresh_token_rotation = true.
+refresh_token_reuse_interval = 10
+# Allow/disallow new user signups to your project.
+enable_signup = true
+# Allow/disallow anonymous sign-ins to your project.
+enable_anonymous_sign_ins = false
+# Allow/disallow testing manual linking of accounts
+enable_manual_linking = false
+# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
+minimum_password_length = 6
+# Passwords that do not meet the following requirements will be rejected as weak. Supported values
+# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
+password_requirements = ""
+
+[auth.rate_limit]
+# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
+email_sent = 2
+# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
+sms_sent = 30
+# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
+anonymous_users = 30
+# Number of sessions that can be refreshed in a 5 minute interval per IP address.
+token_refresh = 150
+# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
+sign_in_sign_ups = 30
+# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
+token_verifications = 30
+# Number of Web3 logins that can be made in a 5 minute interval per IP address.
+web3 = 30
+
+# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
+# [auth.captcha]
+# enabled = true
+# provider = "hcaptcha"
+# secret = ""
+
+[auth.email]
+# Allow/disallow new user signups via email to your project.
+enable_signup = true
+# If enabled, a user will be required to confirm any email change on both the old, and new email
+# addresses. If disabled, only the new email is required to confirm.
+double_confirm_changes = true
+# If enabled, users need to confirm their email address before signing in.
+enable_confirmations = false
+# If enabled, users will need to reauthenticate or have logged in recently to change their password.
+secure_password_change = false
+# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
+max_frequency = "1s"
+# Number of characters used in the email OTP.
+otp_length = 6
+# Number of seconds before the email OTP expires (defaults to 1 hour).
+otp_expiry = 3600
+
+# Use a production-ready SMTP server
+# [auth.email.smtp]
+# enabled = true
+# host = "smtp.sendgrid.net"
+# port = 587
+# user = "apikey"
+# pass = "env(SENDGRID_API_KEY)"
+# admin_email = "admin@email.com"
+# sender_name = "Admin"
+
+# Uncomment to customize email template
+# [auth.email.template.invite]
+# subject = "You have been invited"
+# content_path = "./supabase/templates/invite.html"
+
+# Uncomment to customize notification email template
+# [auth.email.notification.password_changed]
+# enabled = true
+# subject = "Your password has been changed"
+# content_path = "./templates/password_changed_notification.html"
+
+[auth.sms]
+# Allow/disallow new user signups via SMS to your project.
+enable_signup = false
+# If enabled, users need to confirm their phone number before signing in.
+enable_confirmations = false
+# Template for sending OTP to users
+template = "Your code is {{ .Code }}"
+# Controls the minimum amount of time that must pass before sending another sms otp.
+max_frequency = "5s"
+
+# Use pre-defined map of phone number to OTP for testing.
+# [auth.sms.test_otp]
+# 4152127777 = "123456"
+
+# Configure logged in session timeouts.
+# [auth.sessions]
+# Force log out after the specified duration.
+# timebox = "24h"
+# Force log out if the user has been inactive longer than the specified duration.
+# inactivity_timeout = "8h"
+
+# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
+# [auth.hook.before_user_created]
+# enabled = true
+# uri = "pg-functions://postgres/auth/before-user-created-hook"
+
+# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
+# [auth.hook.custom_access_token]
+# enabled = true
+# uri = "pg-functions:////"
+
+# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
+[auth.sms.twilio]
+enabled = false
+account_sid = ""
+message_service_sid = ""
+# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
+auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
+
+# Multi-factor-authentication is available to Supabase Pro plan.
+[auth.mfa]
+# Control how many MFA factors can be enrolled at once per user.
+max_enrolled_factors = 10
+
+# Control MFA via App Authenticator (TOTP)
+[auth.mfa.totp]
+enroll_enabled = false
+verify_enabled = false
+
+# Configure MFA via Phone Messaging
+[auth.mfa.phone]
+enroll_enabled = false
+verify_enabled = false
+otp_length = 6
+template = "Your code is {{ .Code }}"
+max_frequency = "5s"
+
+# Configure MFA via WebAuthn
+# [auth.mfa.web_authn]
+# enroll_enabled = true
+# verify_enabled = true
+
+# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
+# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
+# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
+[auth.external.apple]
+enabled = false
+client_id = ""
+# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
+secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
+# Overrides the default auth redirectUrl.
+redirect_uri = ""
+# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
+# or any other third-party OIDC providers.
+url = ""
+# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
+skip_nonce_check = false
+# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
+email_optional = false
+
+# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
+# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
+[auth.web3.solana]
+enabled = false
+
+# Use Firebase Auth as a third-party provider alongside Supabase Auth.
+[auth.third_party.firebase]
+enabled = false
+# project_id = "my-firebase-project"
+
+# Use Auth0 as a third-party provider alongside Supabase Auth.
+[auth.third_party.auth0]
+enabled = false
+# tenant = "my-auth0-tenant"
+# tenant_region = "us"
+
+# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
+[auth.third_party.aws_cognito]
+enabled = false
+# user_pool_id = "my-user-pool-id"
+# user_pool_region = "us-east-1"
+
+# Use Clerk as a third-party provider alongside Supabase Auth.
+[auth.third_party.clerk]
+enabled = false
+# Obtain from https://clerk.com/setup/supabase
+# domain = "example.clerk.accounts.dev"
+
+# OAuth server configuration
+[auth.oauth_server]
+# Enable OAuth server functionality
+enabled = false
+# Path for OAuth consent flow UI
+authorization_url_path = "/oauth/consent"
+# Allow dynamic client registration
+allow_dynamic_registration = false
+
+[edge_runtime]
+enabled = true
+# Supported request policies: `oneshot`, `per_worker`.
+# `per_worker` (default) — enables hot reload during local development.
+# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
+policy = "per_worker"
+# Port to attach the Chrome inspector for debugging edge functions.
+inspector_port = 8083
+# The Deno major version to use.
+deno_version = 2
+
+# [edge_runtime.secrets]
+# secret_key = "env(SECRET_VALUE)"
+
+[analytics]
+enabled = true
+port = 54327
+# Configure one of the supported backends: `postgres`, `bigquery`.
+backend = "postgres"
+
+# Experimental features may be deprecated any time
+[experimental]
+# Configures Postgres storage engine to use OrioleDB (S3)
+orioledb_version = ""
+# Configures S3 bucket URL, eg. .s3-.amazonaws.com
+s3_host = "env(S3_HOST)"
+# Configures S3 bucket region, eg. us-east-1
+s3_region = "env(S3_REGION)"
+# Configures AWS_ACCESS_KEY_ID for S3 bucket
+s3_access_key = "env(S3_ACCESS_KEY)"
+# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
+s3_secret_key = "env(S3_SECRET_KEY)"
diff --git a/supabase/migrations/20240101000001_create_posts_table.sql b/supabase/migrations/20240101000001_create_posts_table.sql
new file mode 100644
index 0000000..8c872ee
--- /dev/null
+++ b/supabase/migrations/20240101000001_create_posts_table.sql
@@ -0,0 +1,38 @@
+-- Create posts table
+create table if not exists public.posts (
+ id text primary key, -- slug-based ID
+ title text not null,
+ date timestamptz not null,
+ excerpt text not null default '',
+ content text not null default '', -- raw markdown
+ content_html text not null default '', -- pre-rendered HTML
+ author text not null default '',
+ tags text[] not null default '{}',
+ cover_image text,
+ published boolean not null default false,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+-- Indexes
+create index if not exists idx_posts_date on public.posts (date desc);
+create index if not exists idx_posts_published on public.posts (published);
+create index if not exists idx_posts_author on public.posts (author);
+create index if not exists idx_posts_tags on public.posts using gin (tags);
+
+-- Auto-update trigger for updated_at
+create or replace function public.handle_updated_at()
+returns trigger as $$
+begin
+ new.updated_at = now();
+ return new;
+end;
+$$ language plpgsql;
+
+create trigger on_posts_updated
+ before update on public.posts
+ for each row
+ execute function public.handle_updated_at();
+
+-- Enable RLS
+alter table public.posts enable row level security;
diff --git a/supabase/migrations/20240101000002_create_admin_users_table.sql b/supabase/migrations/20240101000002_create_admin_users_table.sql
new file mode 100644
index 0000000..0f05837
--- /dev/null
+++ b/supabase/migrations/20240101000002_create_admin_users_table.sql
@@ -0,0 +1,15 @@
+-- Create admin_users table
+create table if not exists public.admin_users (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid not null references auth.users(id) on delete cascade,
+ email text not null,
+ role text not null default 'editor' check (role in ('super_admin', 'admin', 'editor')),
+ created_at timestamptz not null default now(),
+ unique(user_id)
+);
+
+-- Index on user_id for fast lookups
+create index if not exists idx_admin_users_user_id on public.admin_users (user_id);
+
+-- Enable RLS
+alter table public.admin_users enable row level security;
diff --git a/supabase/migrations/20240101000003_create_existing_tables.sql b/supabase/migrations/20240101000003_create_existing_tables.sql
new file mode 100644
index 0000000..a9363ad
--- /dev/null
+++ b/supabase/migrations/20240101000003_create_existing_tables.sql
@@ -0,0 +1,35 @@
+-- Create tables that were originally created via Supabase dashboard.
+-- Using IF NOT EXISTS so this is safe to run on existing databases.
+
+-- Comments table
+create table if not exists public.comments (
+ id bigint generated always as identity primary key,
+ name text not null,
+ email text not null,
+ comment text not null,
+ post_id text not null,
+ created_at timestamptz not null default now()
+);
+
+alter table public.comments enable row level security;
+
+-- Subscriptions table
+create table if not exists public.subscriptions (
+ id bigint generated always as identity primary key,
+ email text not null unique,
+ created_at timestamptz not null default now()
+);
+
+alter table public.subscriptions enable row level security;
+
+-- Authors table
+create table if not exists public.authors (
+ name text primary key,
+ bio text not null default '',
+ x_link text,
+ fb_link text,
+ insta_link text,
+ pfp_link text
+);
+
+alter table public.authors enable row level security;
diff --git a/supabase/migrations/20240101000004_rls_policies.sql b/supabase/migrations/20240101000004_rls_policies.sql
new file mode 100644
index 0000000..103d4a6
--- /dev/null
+++ b/supabase/migrations/20240101000004_rls_policies.sql
@@ -0,0 +1,151 @@
+-- Helper function: check if current user is an admin
+create or replace function public.is_admin()
+returns boolean as $$
+begin
+ return exists (
+ select 1 from public.admin_users
+ where user_id = auth.uid()
+ );
+end;
+$$ language plpgsql security definer;
+
+-- Helper function: check if current user has a specific role or higher
+create or replace function public.has_admin_role(required_role text)
+returns boolean as $$
+declare
+ user_role text;
+begin
+ select role into user_role from public.admin_users
+ where user_id = auth.uid();
+
+ if user_role is null then
+ return false;
+ end if;
+
+ -- Role hierarchy: super_admin > admin > editor
+ if required_role = 'editor' then
+ return user_role in ('editor', 'admin', 'super_admin');
+ elsif required_role = 'admin' then
+ return user_role in ('admin', 'super_admin');
+ elsif required_role = 'super_admin' then
+ return user_role = 'super_admin';
+ end if;
+
+ return false;
+end;
+$$ language plpgsql security definer;
+
+-- ============================================
+-- POSTS policies (drop first in case of partial previous run)
+-- ============================================
+drop policy if exists "Public can read published posts" on public.posts;
+drop policy if exists "Editors can insert posts" on public.posts;
+drop policy if exists "Editors can update posts" on public.posts;
+drop policy if exists "Admins can delete posts" on public.posts;
+
+create policy "Public can read published posts"
+ on public.posts for select
+ using (published = true or public.is_admin());
+
+create policy "Editors can insert posts"
+ on public.posts for insert
+ with check (public.has_admin_role('editor'));
+
+create policy "Editors can update posts"
+ on public.posts for update
+ using (public.has_admin_role('editor'));
+
+create policy "Admins can delete posts"
+ on public.posts for delete
+ using (public.has_admin_role('admin'));
+
+-- ============================================
+-- COMMENTS policies
+-- ============================================
+drop policy if exists "Public can read comments" on public.comments;
+drop policy if exists "Public can insert comments" on public.comments;
+drop policy if exists "Editors can delete comments" on public.comments;
+drop policy if exists "Editors can update comments" on public.comments;
+
+create policy "Public can read comments"
+ on public.comments for select
+ using (true);
+
+create policy "Public can insert comments"
+ on public.comments for insert
+ with check (true);
+
+create policy "Editors can delete comments"
+ on public.comments for delete
+ using (public.has_admin_role('editor'));
+
+create policy "Editors can update comments"
+ on public.comments for update
+ using (public.has_admin_role('editor'));
+
+-- ============================================
+-- SUBSCRIPTIONS policies
+-- ============================================
+drop policy if exists "Public can insert subscriptions" on public.subscriptions;
+drop policy if exists "Public can read subscriptions" on public.subscriptions;
+drop policy if exists "Admins can delete subscriptions" on public.subscriptions;
+
+create policy "Public can insert subscriptions"
+ on public.subscriptions for insert
+ with check (true);
+
+create policy "Public can read subscriptions"
+ on public.subscriptions for select
+ using (true);
+
+create policy "Admins can delete subscriptions"
+ on public.subscriptions for delete
+ using (public.has_admin_role('admin'));
+
+-- ============================================
+-- AUTHORS policies
+-- ============================================
+drop policy if exists "Public can read authors" on public.authors;
+drop policy if exists "Admins can insert authors" on public.authors;
+drop policy if exists "Admins can update authors" on public.authors;
+drop policy if exists "Super admins can delete authors" on public.authors;
+
+create policy "Public can read authors"
+ on public.authors for select
+ using (true);
+
+create policy "Admins can insert authors"
+ on public.authors for insert
+ with check (public.has_admin_role('admin'));
+
+create policy "Admins can update authors"
+ on public.authors for update
+ using (public.has_admin_role('admin'));
+
+create policy "Super admins can delete authors"
+ on public.authors for delete
+ using (public.has_admin_role('super_admin'));
+
+-- ============================================
+-- ADMIN_USERS policies
+-- ============================================
+drop policy if exists "Admins can read admin users" on public.admin_users;
+drop policy if exists "Super admins can insert admin users" on public.admin_users;
+drop policy if exists "Super admins can update admin users" on public.admin_users;
+drop policy if exists "Super admins can delete admin users" on public.admin_users;
+
+create policy "Admins can read admin users"
+ on public.admin_users for select
+ using (public.is_admin());
+
+create policy "Super admins can insert admin users"
+ on public.admin_users for insert
+ with check (public.has_admin_role('super_admin'));
+
+create policy "Super admins can update admin users"
+ on public.admin_users for update
+ using (public.has_admin_role('super_admin'));
+
+create policy "Super admins can delete admin users"
+ on public.admin_users for delete
+ using (public.has_admin_role('super_admin'));
diff --git a/tsconfig.json b/tsconfig.json
index d8b9323..e7ff3a2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,9 +23,19 @@
}
],
"paths": {
- "@/*": ["./*"]
+ "@/*": [
+ "./*"
+ ]
}
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
}
diff --git a/types/index.ts b/types/index.ts
index 1631cca..21719fa 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -1,20 +1,64 @@
export interface PostData {
- id: string;
- title: string;
- date: string;
- excerpt: string;
- content: string;
- author: string;
- tags: string[];
- coverImage?: string;
- }
-
- export interface PostMetadata {
- id: string;
- title: string;
- date: string;
- excerpt: string;
- author: string;
- tags: string[];
- coverImage?: string;
- }
\ No newline at end of file
+ id: string;
+ title: string;
+ date: string;
+ excerpt: string;
+ content: string;
+ author: string;
+ tags: string[];
+ coverImage?: string;
+}
+
+export interface PostMetadata {
+ id: string;
+ title: string;
+ date: string;
+ excerpt: string;
+ author: string;
+ tags: string[];
+ coverImage?: string;
+}
+
+export interface PostFormData {
+ id: string;
+ title: string;
+ date: string;
+ excerpt: string;
+ content: string;
+ author: string;
+ tags: string[];
+ coverImage?: string;
+ published: boolean;
+}
+
+export interface AdminUser {
+ id: string;
+ user_id: string;
+ email: string;
+ role: "super_admin" | "admin" | "editor";
+ created_at: string;
+}
+
+export interface Comment {
+ id: number;
+ name: string;
+ email: string;
+ comment: string;
+ post_id: string;
+ created_at: string;
+}
+
+export interface Subscription {
+ id: number;
+ email: string;
+ created_at: string;
+}
+
+export interface Author {
+ name: string;
+ bio: string;
+ x_link?: string;
+ fb_link?: string;
+ insta_link?: string;
+ pfp_link?: string;
+}
diff --git a/utils/supabase.ts b/utils/supabase.ts
deleted file mode 100644
index 7fd5605..0000000
--- a/utils/supabase.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { createClient } from "@supabase/supabase-js";
-
-const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
-const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
-
-export const supabase = createClient(supabaseUrl, supabaseKey);
\ No newline at end of file
diff --git a/utils/supabase/client.ts b/utils/supabase/client.ts
new file mode 100644
index 0000000..2abf5b7
--- /dev/null
+++ b/utils/supabase/client.ts
@@ -0,0 +1,8 @@
+import { createBrowserClient } from "@supabase/ssr";
+
+export function createClient() {
+ return createBrowserClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!
+ );
+}
diff --git a/utils/supabase/middleware.ts b/utils/supabase/middleware.ts
new file mode 100644
index 0000000..b4d906c
--- /dev/null
+++ b/utils/supabase/middleware.ts
@@ -0,0 +1,78 @@
+import { createServerClient } from "@supabase/ssr";
+import { NextResponse, type NextRequest } from "next/server";
+
+export async function updateSession(request: NextRequest) {
+ let supabaseResponse = NextResponse.next({
+ request,
+ });
+
+ const supabase = createServerClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!,
+ {
+ cookies: {
+ getAll() {
+ return request.cookies.getAll();
+ },
+ setAll(cookiesToSet) {
+ cookiesToSet.forEach(({ name, value }) =>
+ request.cookies.set(name, value)
+ );
+ supabaseResponse = NextResponse.next({
+ request,
+ });
+ cookiesToSet.forEach(({ name, value, options }) =>
+ supabaseResponse.cookies.set(name, value, options)
+ );
+ },
+ },
+ }
+ );
+
+ // Refresh the session
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ // Protect admin routes (except login)
+ const isAdminRoute = request.nextUrl.pathname.startsWith("/admin");
+ const isLoginPage = request.nextUrl.pathname === "/admin/login";
+
+ if (isAdminRoute && !isLoginPage) {
+ if (!user) {
+ const url = request.nextUrl.clone();
+ url.pathname = "/admin/login";
+ return NextResponse.redirect(url);
+ }
+
+ // Verify the user is in admin_users table
+ const { data: adminUser } = await supabase
+ .from("admin_users")
+ .select("role")
+ .eq("user_id", user.id)
+ .single();
+
+ if (!adminUser) {
+ const url = request.nextUrl.clone();
+ url.pathname = "/admin/login";
+ return NextResponse.redirect(url);
+ }
+ }
+
+ // If logged-in admin visits login page, redirect to dashboard
+ if (isLoginPage && user) {
+ const { data: adminUser } = await supabase
+ .from("admin_users")
+ .select("role")
+ .eq("user_id", user.id)
+ .single();
+
+ if (adminUser) {
+ const url = request.nextUrl.clone();
+ url.pathname = "/admin";
+ return NextResponse.redirect(url);
+ }
+ }
+
+ return supabaseResponse;
+}
diff --git a/utils/supabase/server.ts b/utils/supabase/server.ts
new file mode 100644
index 0000000..6210a91
--- /dev/null
+++ b/utils/supabase/server.ts
@@ -0,0 +1,28 @@
+import { createServerClient } from "@supabase/ssr";
+import { cookies } from "next/headers";
+
+export async function createClient() {
+ const cookieStore = await cookies();
+
+ return createServerClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!,
+ {
+ cookies: {
+ getAll() {
+ return cookieStore.getAll();
+ },
+ setAll(cookiesToSet) {
+ try {
+ cookiesToSet.forEach(({ name, value, options }) =>
+ cookieStore.set(name, value, options)
+ );
+ } catch {
+ // The `setAll` method was called from a Server Component.
+ // This can be ignored if you have middleware refreshing sessions.
+ }
+ },
+ },
+ }
+ );
+}