diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..c9b0fe0
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+node_modules
+.next
+.git
+.env
+.env.local
+Dockerfile
+.dockerignore
+README.md
+node-*
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
new file mode 100644
index 0000000..7e343ee
--- /dev/null
+++ b/.gitea/workflows/deploy.yml
@@ -0,0 +1,16 @@
+name: build-and-publish
+on:
+ push:
+ branches: [master, main]
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Log in to the Gitea registry
+ run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thebennett.net -u "${{ secrets.REGISTRY_USER }}" --password-stdin
+ - name: Build + push
+ run: |
+ docker build -t git.thebennett.net/reformedwitness/confessions-of-grace:latest -t git.thebennett.net/reformedwitness/confessions-of-grace:${{ github.sha }} .
+ docker push git.thebennett.net/reformedwitness/confessions-of-grace:latest
+ docker push git.thebennett.net/reformedwitness/confessions-of-grace:${{ github.sha }}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..4cc28c6
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,24 @@
+# syntax=docker/dockerfile:1
+FROM node:22-alpine AS deps
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+
+FROM node:22-alpine AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+ENV NEXT_TELEMETRY_DISABLED=1
+RUN npm run build
+
+FROM node:22-alpine AS runner
+WORKDIR /app
+ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0
+RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/data ./data
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+USER nextjs
+EXPOSE 3000
+CMD ["node", "server.js"]
diff --git a/app/admin/(dashboard)/authors/[name]/edit/page.tsx b/app/admin/(dashboard)/authors/[name]/edit/page.tsx
deleted file mode 100644
index 949f016..0000000
--- a/app/admin/(dashboard)/authors/[name]/edit/page.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-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
deleted file mode 100644
index e1f2350..0000000
--- a/app/admin/(dashboard)/authors/actions.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-"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
deleted file mode 100644
index d3ca581..0000000
--- a/app/admin/(dashboard)/authors/new/page.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-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
deleted file mode 100644
index 22583df..0000000
--- a/app/admin/(dashboard)/authors/page.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-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
-
-
-
- Delete
-
-
-
-
- ))}
-
-
- {(!authors || authors.length === 0) && (
-
No authors found.
- )}
-
-
- );
-}
diff --git a/app/admin/(dashboard)/comments/actions.ts b/app/admin/(dashboard)/comments/actions.ts
deleted file mode 100644
index 8017160..0000000
--- a/app/admin/(dashboard)/comments/actions.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-"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
deleted file mode 100644
index acfe047..0000000
--- a/app/admin/(dashboard)/comments/page.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-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()}
-
-
-
-
- Delete
-
-
-
-
- ))}
-
-
- {(!comments || comments.length === 0) && (
-
No comments found.
- )}
-
-
- );
-}
diff --git a/app/admin/(dashboard)/components/AdminSidebar.tsx b/app/admin/(dashboard)/components/AdminSidebar.tsx
deleted file mode 100644
index c0b9465..0000000
--- a/app/admin/(dashboard)/components/AdminSidebar.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-"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
deleted file mode 100644
index 348278f..0000000
--- a/app/admin/(dashboard)/components/DeleteConfirmDialog.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-"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}
-
- setOpen(false)}
- className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
- disabled={loading}
- >
- Cancel
-
-
- {loading ? "Deleting..." : "Delete"}
-
-
-
-
- )}
- >
- );
-}
diff --git a/app/admin/(dashboard)/components/PostEditor.tsx b/app/admin/(dashboard)/components/PostEditor.tsx
deleted file mode 100644
index 1d955b3..0000000
--- a/app/admin/(dashboard)/components/PostEditor.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-"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 (
-
- {/* Slug / ID */}
-
-
- Slug (URL ID)
-
-
-
-
- {/* Title */}
-
-
- Title
-
-
-
-
- {/* Date */}
-
-
- Date
-
-
-
-
- {/* Author */}
-
-
- Author
-
-
- Select an author
- {authors.map((a) => (
-
- {a.name}
-
- ))}
-
-
-
- {/* Excerpt */}
-
-
- Excerpt
-
-
-
-
- {/* Tags */}
-
-
- Tags (comma-separated)
-
-
-
-
- {/* Cover Image */}
-
-
- Cover Image URL
-
-
-
-
- {/* Content (Markdown) */}
-
-
- Content (Markdown)
-
- setContent(e.target.value)}
- required
- className="w-full px-4 py-2 border border-gray-300 rounded-md font-mono text-sm focus:outline-none focus:ring-2 focus:ring-accent"
- />
-
-
- {/* Published Toggle */}
-
-
- setPublished(!published)}
- className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
- published ? "bg-green-500" : "bg-gray-300"
- }`}
- >
-
-
-
- {published ? "Published" : "Draft"}
-
-
-
- {/* Submit */}
-
-
- {isEdit ? "Update Post" : "Create Post"}
-
-
- Cancel
-
-
-
- );
-}
diff --git a/app/admin/(dashboard)/layout.tsx b/app/admin/(dashboard)/layout.tsx
deleted file mode 100644
index 54c51e4..0000000
--- a/app/admin/(dashboard)/layout.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-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
deleted file mode 100644
index f79e946..0000000
--- a/app/admin/(dashboard)/page.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-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
deleted file mode 100644
index 2d5762f..0000000
--- a/app/admin/(dashboard)/posts/[id]/edit/page.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-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
deleted file mode 100644
index 38ed390..0000000
--- a/app/admin/(dashboard)/posts/actions.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-"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
deleted file mode 100644
index 78a135b..0000000
--- a/app/admin/(dashboard)/posts/new/page.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-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
deleted file mode 100644
index 0ca7982..0000000
--- a/app/admin/(dashboard)/posts/page.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-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
-
-
-
- Delete
-
-
-
-
- ))}
-
-
- {(!posts || posts.length === 0) && (
-
No posts found.
- )}
-
-
- );
-}
diff --git a/app/admin/(dashboard)/subscriptions/ExportButton.tsx b/app/admin/(dashboard)/subscriptions/ExportButton.tsx
deleted file mode 100644
index 59686e2..0000000
--- a/app/admin/(dashboard)/subscriptions/ExportButton.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-"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 (
-
- Export CSV
-
- );
-}
diff --git a/app/admin/(dashboard)/subscriptions/actions.ts b/app/admin/(dashboard)/subscriptions/actions.ts
deleted file mode 100644
index c5efff7..0000000
--- a/app/admin/(dashboard)/subscriptions/actions.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-"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
deleted file mode 100644
index 4c835f1..0000000
--- a/app/admin/(dashboard)/subscriptions/page.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-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()}
-
-
-
-
- Delete
-
-
-
-
- ))}
-
-
- {(!subscriptions || subscriptions.length === 0) && (
-
- No subscriptions found.
-
- )}
-
-
- );
-}
diff --git a/app/admin/(dashboard)/users/actions.ts b/app/admin/(dashboard)/users/actions.ts
deleted file mode 100644
index 25ce4a7..0000000
--- a/app/admin/(dashboard)/users/actions.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-"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
deleted file mode 100644
index 4293dec..0000000
--- a/app/admin/(dashboard)/users/page.tsx
+++ /dev/null
@@ -1,198 +0,0 @@
-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}
-
-
- {
- "use server";
- }}
- className="text-sm border border-gray-300 rounded px-2 py-1"
- disabled={adminUser.user_id === user.id}
- >
- Editor
- Admin
- Super Admin
-
-
-
-
- {new Date(adminUser.created_at).toLocaleDateString()}
-
-
- {adminUser.user_id !== user.id && (
-
- {(["editor", "admin", "super_admin"] as const)
- .filter((r) => r !== adminUser.role)
- .map((role) => (
-
-
- Make {role.replace("_", " ")}
-
-
- ))}
-
-
- Remove
-
-
-
- )}
-
-
- ))}
-
-
- {(!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.
-
-
{
- "use server";
- const { createClient } = await import(
- "@/utils/supabase/server"
- );
- const { revalidatePath } = await import("next/cache");
- const supabase = await createClient();
- const email = formData.get("email") as string;
- const role = formData.get("role") as string;
- const userId = formData.get("user_id") as string;
-
- await supabase.from("admin_users").insert({
- email,
- role,
- user_id: userId,
- });
-
- revalidatePath("/admin/users");
- }}
- className="space-y-4"
- >
-
-
- Email
-
-
-
-
-
- Auth User ID (UUID)
-
-
-
-
-
- Role
-
-
- Editor
- Admin
- Super Admin
-
-
-
- Add Admin
-
-
-
-
- );
-}
diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx
deleted file mode 100644
index a7b5b40..0000000
--- a/app/admin/layout.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-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
deleted file mode 100644
index e6a31a5..0000000
--- a/app/admin/login/page.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-"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}
-
- )}
-
-
-
-
- Email
-
- setEmail(e.target.value)}
- className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
- required
- />
-
-
-
-
- Password
-
- setPassword(e.target.value)}
- className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
- required
- />
-
-
-
- {loading ? "Signing in..." : "Sign In"}
-
-
-
-
- );
-}
diff --git a/app/api/comments/route.ts b/app/api/comments/route.ts
deleted file mode 100644
index 8fc7ec2..0000000
--- a/app/api/comments/route.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-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/route.ts b/app/api/subscribe/route.ts
deleted file mode 100644
index ef25312..0000000
--- a/app/api/subscribe/route.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-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 f96a8e4..47d2edc 100644
--- a/app/authors/[author]/AuthorProfile.tsx
+++ b/app/authors/[author]/AuthorProfile.tsx
@@ -1,105 +1,27 @@
-'use client';
-
-import React, { useEffect, useState } from 'react';
-import { createClient } from '@/utils/supabase/client';
-
-const supabase = createClient();
+import React from 'react';
import { PostMetadata } from '@/types';
-interface AuthorProfile {
- name: string;
- bio: string;
- x_link?: string;
- fb_link?: string;
- insta_link?: string;
- pfp_link?: string;
-}
-
interface AuthorProfileProps {
author: string;
posts: PostMetadata[];
}
-export default function AuthorProfile({ author, posts }: AuthorProfileProps) {
- const [authorProfile, setAuthorProfile] = useState(null);
-
- const fetchAuthorProfile = async () => {
- const { data, error } = await supabase
- .from('authors')
- .select('name, bio, x_link, fb_link, insta_link, pfp_link')
- .eq('name', author)
- .single();
-
- if (error) {
- console.warn('No author profile found for:', author, '→', error.message);
- }
-
- if (data) {
- setAuthorProfile(data);
- }
- };
-
- useEffect(() => {
- fetchAuthorProfile();
- }, [author]);
-
+// Static author profile. No bio/social data is available in the markdown
+// source, so we render the author's name and a placeholder avatar only.
+export default function AuthorProfile({ author }: AuthorProfileProps) {
return (
- {/* Profile Picture */}
- {authorProfile?.pfp_link ? (
-
- ) : (
-
- ?
-
- )}
+ {/* Placeholder avatar */}
+
+ {author.charAt(0).toUpperCase()}
+
{/* Author Name */}
- {authorProfile?.name || author}
+ {author}
-
- {/* Author Bio */}
- {authorProfile?.bio && (
-
{authorProfile.bio}
- )}
-
- {/* Social Links */}
-
- {authorProfile?.x_link && (
-
-
-
- )}
- {authorProfile?.fb_link && (
-
-
-
- )}
- {authorProfile?.insta_link && (
-
-
-
- )}
-
);
-}
\ No newline at end of file
+}
diff --git a/app/authors/[author]/page.tsx b/app/authors/[author]/page.tsx
index dda73d1..2a6bdcd 100644
--- a/app/authors/[author]/page.tsx
+++ b/app/authors/[author]/page.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import PostCard from '@/components/PostCard';
import { getPostsByAuthor } from '@/lib/posts';
+import { getAllAuthors } from '@/lib/authors';
import { PostMetadata } from '@/types';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
@@ -10,13 +11,19 @@ interface PageProps {
params: Promise<{ author: string }>;
}
-export const dynamic = 'force-dynamic';
+export async function generateStaticParams() {
+ const authors = await getAllAuthors();
+ return authors.map((author) => ({
+ author: author.slug,
+ }));
+}
export async function generateMetadata({ params }: PageProps): Promise {
const { author } = await params;
+ const decodedAuthor = decodeURIComponent(author);
return createMetadata({
- title: `${author} | Author`,
- description: `Posts authored by ${author} on Confessions of Grace.`,
+ title: `${decodedAuthor} | Author`,
+ description: `Posts authored by ${decodedAuthor} on Confessions of Grace.`,
url: `https://confessionsofgrace.com/authors/${author}`,
type: 'website'
});
@@ -28,15 +35,16 @@ async function getPostsByAuthorData(author: string): Promise {
export default async function AuthorPage({ params }: PageProps) {
const { author } = await params;
- const posts = await getPostsByAuthorData(author);
+ const decodedAuthor = decodeURIComponent(author);
+ const posts = await getPostsByAuthorData(decodedAuthor);
return (
-
+
{/* Posts */}
- {posts.length} {posts.length === 1 ? 'post' : 'posts'} authored by "{author}"
+ {posts.length} {posts.length === 1 ? 'post' : 'posts'} authored by "{decodedAuthor}"
{posts.length > 0 ? (
@@ -52,4 +60,4 @@ export default async function AuthorPage({ params }: PageProps) {
)}
);
-}
\ No newline at end of file
+}
diff --git a/app/authors/page.tsx b/app/authors/page.tsx
index 3c49dbd..7d69b89 100644
--- a/app/authors/page.tsx
+++ b/app/authors/page.tsx
@@ -1,21 +1,9 @@
-export const dynamic = 'force-dynamic';
-
import React from 'react';
import Link from 'next/link';
-import Image from 'next/image';
-import { createClient } from '@/utils/supabase/server';
+import { getAllAuthors } from '@/lib/authors';
import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next';
-interface AuthorProfile {
- name: string;
- bio: string;
- x_link?: string;
- fb_link?: string;
- insta_link?: string;
- pfp_link?: string;
-}
-
export async function generateMetadata(): Promise {
return createMetadata({
title: 'Authors',
@@ -25,27 +13,8 @@ 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');
-
- if (error || !authorsData) {
- console.error('Error fetching authors:', error);
- return [];
- }
-
- return authorsData;
- } catch (error) {
- console.error('Failed to fetch authors during build:', error);
- return [];
- }
-}
-
export default async function AuthorsPage() {
- const authors = await getAuthors();
+ const authors = await getAllAuthors();
return (
@@ -55,20 +24,16 @@ export default async function AuthorsPage() {
{authors.map((author) => (
-
-
+
+ {author.name.charAt(0).toUpperCase()}
{author.name}
- {/* You can include bio or social icons here */}
+
+ {author.postCount} {author.postCount === 1 ? 'post' : 'posts'}
+
))}
@@ -80,4 +45,4 @@ export default async function AuthorsPage() {
);
-}
\ No newline at end of file
+}
diff --git a/app/posts/[id]/page.tsx b/app/posts/[id]/page.tsx
index 0b01800..2e08575 100644
--- a/app/posts/[id]/page.tsx
+++ b/app/posts/[id]/page.tsx
@@ -7,7 +7,6 @@ import { format } from "date-fns";
import Image from "next/image";
import Link from "next/link";
import React from "react";
-import CommentSection from "@/components/CommentSection";
import { generateMetadata as createMetadata } from "@/components/Metadata";
import type { Metadata } from "next";
@@ -136,8 +135,6 @@ export default async function PostPage({ params }: PageProps) {
-
-
= ({ postId }) => {
- const [name, setName] = useState('');
- const [email, setEmail] = useState('');
- const [comment, setComment] = useState('');
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [isSubmitted, setIsSubmitted] = useState(false);
- const [error, setError] = useState
(null);
- const [comments, setComments] = useState([]);
- const [isLoadingComments, setIsLoadingComments] = useState(true); // Add loading state
-
- // Fetch comments when the component mounts or postId changes
- useEffect(() => {
- const fetchComments = async () => {
- setIsLoadingComments(true); // Set loading to true
- const { data, error } = await supabase
- .from('comments') // Replace 'comments' with your Supabase table name
- .select('id, name, comment, created_at') // Select specific columns
- .eq('post_id', postId) // Filter by post_id
- .order('created_at', { ascending: false }); // Order by creation date
-
- if (error) {
- console.error('Error fetching comments:', error);
- setError('Failed to load comments.'); // Display error to user
- setComments([]); // Clear comments on error
- } else {
- setComments(data || []); // Set comments (handle case where data is null)
- setError(null); // Clear any previous errors
- }
- setIsLoadingComments(false); // Set loading to false
- };
-
- fetchComments();
-
- // Optional: Set up real-time subscriptions for new comments
- // Be mindful of performance and resource usage with real-time subscriptions
- // This is a basic example; you might need more sophisticated handling
- const subscription = supabase
- .channel(`comments:post_id=eq.${postId}`)
- .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'comments', filter: `post_id=eq.${postId}` }, (payload) => {
- // Add the new comment to the beginning of the list
- setComments((currentComments) => [payload.new as Comment, ...currentComments]);
- })
- .subscribe();
-
- // Cleanup the subscription when the component unmounts or postId changes
- return () => {
- supabase.removeChannel(subscription);
- };
-
- }, [postId]); // Dependency array includes postId
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
-
- if (!name.trim() || !email.trim() || !comment.trim()) {
- setError('All fields are required');
- return;
- }
-
- setIsSubmitting(true);
- setError(null);
-
- try {
- const { data, error } = await supabase
- .from('comments') // Replace 'comments' with your Supabase table name
- .insert([
- {
- name,
- email, // Storing email is dependent on your privacy policy and RLS
- comment,
- post_id: postId, // Assuming your Supabase column is named 'post_id'
- // created_at will likely be automatically set by Supabase with a default value
- },
- ])
- .select('id, name, comment, created_at'); // Select the inserted data
-
- if (error) {
- console.error('Error inserting comment:', error);
- setError(error.message || 'Something went wrong. Please try again later.');
- } else {
- // Assuming real-time subscription is active,
- // the new comment will be added to the comments state automatically.
- // If not using real-time, you would manually add the new comment here:
- // if (data && data.length > 0) {
- // setComments((currentComments) => [data[0], ...currentComments]);
- // }
-
- setName('');
- setEmail('');
- setComment('');
- setIsSubmitted(true);
- // No need to re-fetch all comments if using real-time subscriptions
-
- setTimeout(() => setIsSubmitted(false), 5000);
- }
- } catch (err) {
- console.error('Unexpected error during submission:', err);
- setError('Something went wrong. Please try again later.');
- } finally {
- setIsSubmitting(false);
- }
- };
-
- return (
-
-
Leave a Comment
-
- {isSubmitted && (
-
- Your comment has been submitted. Thank you!
-
- )}
-
- {error && (
-
- {error}
-
- )}
-
-
-
-
-
-
- Comment *
-
-
-
-
- {/* The save info checkbox logic can remain as it interacts with localStorage */}
- {/*
- {
- if (typeof window !== 'undefined') {
- const saveInfo = e.target.checked;
- localStorage.setItem('saveInfo', saveInfo.toString());
- if (saveInfo) {
- localStorage.setItem('name', name);
- localStorage.setItem('email', email);
- } else {
- localStorage.removeItem('name');
- localStorage.removeItem('email');
- }
- }
- }}
- />
-
- Save my name and email for the next time I comment
-
-
*/}
-
-
- {isSubmitting ? 'Submitting...' : 'Post Comment'}
-
-
-
-
-
Comments ({comments.length})
-
- {isLoadingComments ? (
-
Loading comments...
- ) : comments.length === 0 ? (
-
No comments yet. Be the first to leave one!
- ) : (
-
- {comments.map((comment) => (
-
-
-
-
{comment.name}
-
- {new Date(comment.created_at).toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- })}
-
-
-
-
{comment.comment}
-
- ))}
-
- )}
-
-
- );
-};
-
-export default CommentSection;
\ No newline at end of file
diff --git a/components/SubscribeForm.tsx b/components/SubscribeForm.tsx
index f0c6749..0dedfa0 100644
--- a/components/SubscribeForm.tsx
+++ b/components/SubscribeForm.tsx
@@ -8,6 +8,9 @@ interface SubscribeFormProps {
className?: string;
}
+// Email subscriptions require a backend which has been removed for the static
+// build. This form is a no-op: it never calls any API and simply informs the
+// user that subscriptions are unavailable.
const SubscribeForm: React.FC = ({
placeholder = 'Your email',
buttonLabel = 'Subscribe',
@@ -15,40 +18,10 @@ const SubscribeForm: React.FC = ({
}) => {
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
- const [loading, setLoading] = useState(false);
- const handleSubscribe = async (e: React.FormEvent) => {
+ const handleSubscribe = (e: React.FormEvent) => {
e.preventDefault();
-
- if (!email) {
- setMessage('Please enter your email.');
- return;
- }
-
- setLoading(true);
- setMessage('');
-
- try {
- const response = await fetch('/api/subscribe', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ email }),
- });
-
- if (response.ok) {
- setMessage('You have successfully subscribed!');
- setEmail('');
- } else {
- const errorData = await response.json();
- setMessage(`Error: ${errorData.message}`);
- }
- } catch (error) {
- setMessage('Something went wrong. Please try again.');
- } finally {
- setLoading(false);
- }
+ setMessage('Subscriptions are currently unavailable.');
};
return (
@@ -65,13 +38,12 @@ const SubscribeForm: React.FC = ({
- {loading ? 'Subscribing...' : buttonLabel}
+ {buttonLabel}
{message && {message}
}
);
};
-export default SubscribeForm;
\ No newline at end of file
+export default SubscribeForm;
diff --git a/lib/authors.ts b/lib/authors.ts
new file mode 100644
index 0000000..c5725b6
--- /dev/null
+++ b/lib/authors.ts
@@ -0,0 +1,57 @@
+import { getSortedPostsData, getPostsByAuthor } from "@/lib/posts";
+import { PostMetadata } from "@/types";
+
+export interface AuthorSummary {
+ name: string;
+ slug: string;
+ postCount: number;
+ // No bio data is available in the static build; these remain optional.
+ bio?: string;
+ x_link?: string;
+ fb_link?: string;
+ insta_link?: string;
+ pfp_link?: string;
+}
+
+export interface AuthorWithPosts extends AuthorSummary {
+ posts: PostMetadata[];
+}
+
+/**
+ * Derive the list of authors from the posts' frontmatter.
+ * The author `slug` is the author name itself, which is what
+ * `getPostsByAuthor` filters on and what the `[author]` route uses.
+ */
+export async function getAllAuthors(): Promise {
+ const posts = await getSortedPostsData();
+
+ const counts = new Map();
+ for (const post of posts) {
+ if (!post.author) continue;
+ counts.set(post.author, (counts.get(post.author) || 0) + 1);
+ }
+
+ return Array.from(counts.entries())
+ .map(([name, postCount]) => ({
+ name,
+ slug: name,
+ postCount,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+}
+
+export async function getAuthor(
+ name: string,
+): Promise {
+ const posts = await getPostsByAuthor(name);
+ if (posts.length === 0) {
+ return null;
+ }
+
+ return {
+ name,
+ slug: name,
+ postCount: posts.length,
+ posts,
+ };
+}
diff --git a/lib/posts.ts b/lib/posts.ts
index 482b3b8..0d7868d 100644
--- a/lib/posts.ts
+++ b/lib/posts.ts
@@ -1,125 +1,94 @@
-import { createClient } from "@/utils/supabase/server";
+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";
-export async function getSortedPostsData(): Promise {
- const supabase = await createClient();
+const postsDirectory = path.join(process.cwd(), "data/posts");
- const { data, error } = await supabase
- .from("posts")
- .select("id, title, date, excerpt, author, tags, cover_image")
- .eq("published", true)
- .order("date", { ascending: false });
+interface PostFrontmatter {
+ title: string;
+ date: string;
+ author: string;
+ excerpt: string;
+ tags?: string[];
+ coverImage?: string;
+}
- if (error) {
- console.error("Error fetching posts:", error);
+function getPostIds(): string[] {
+ if (!fs.existsSync(postsDirectory)) {
return [];
}
+ return fs
+ .readdirSync(postsDirectory)
+ .filter((fileName) => fileName.endsWith(".md"))
+ .map((fileName) => fileName.replace(/\.md$/, ""));
+}
- 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,
- }));
+function readPostMetadata(id: string): PostMetadata {
+ const fullPath = path.join(postsDirectory, `${id}.md`);
+ const fileContents = fs.readFileSync(fullPath, "utf8");
+ const { data } = matter(fileContents);
+ const frontmatter = data as PostFrontmatter;
+
+ return {
+ id,
+ title: frontmatter.title,
+ date: frontmatter.date,
+ excerpt: frontmatter.excerpt,
+ author: frontmatter.author,
+ tags: frontmatter.tags || [],
+ coverImage: frontmatter.coverImage || undefined,
+ };
+}
+
+export async function getSortedPostsData(): Promise {
+ const posts = getPostIds().map((id) => readPostMetadata(id));
+
+ return posts.sort((a, b) => (a.date < b.date ? 1 : -1));
}
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 },
+ return getPostIds().map((id) => ({
+ params: { id },
}));
}
export async function getPostData(id: string): Promise {
- const supabase = await createClient();
+ const fullPath = path.join(postsDirectory, `${id}.md`);
- 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);
+ if (!fs.existsSync(fullPath)) {
throw new Error(`Post not found: ${id}`);
}
+ const fileContents = fs.readFileSync(fullPath, "utf8");
+ const { data, content } = matter(fileContents);
+ const frontmatter = data as PostFrontmatter;
+
+ const processedContent = await remark().use(html).process(content);
+ const contentHtml = processedContent.toString();
+
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,
+ id,
+ title: frontmatter.title,
+ date: frontmatter.date,
+ excerpt: frontmatter.excerpt,
+ content: contentHtml,
+ author: frontmatter.author,
+ tags: frontmatter.tags || [],
+ coverImage: frontmatter.coverImage || 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,
- }));
+ const posts = await getSortedPostsData();
+ return posts.filter((post) => post.tags.includes(tag));
}
export async function getPostsByAuthor(
- author: string
+ 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,
- }));
+ const posts = await getSortedPostsData();
+ return posts.filter((post) => post.author === author);
}
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..68a6c64 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ output: "standalone",
};
export default nextConfig;
diff --git a/proxy.ts b/proxy.ts
deleted file mode 100644
index 2c70531..0000000
--- a/proxy.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-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
deleted file mode 100644
index 166642d..0000000
--- a/scripts/migrate-posts.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-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/utils/supabase/client.ts b/utils/supabase/client.ts
deleted file mode 100644
index 2abf5b7..0000000
--- a/utils/supabase/client.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-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
deleted file mode 100644
index b4d906c..0000000
--- a/utils/supabase/middleware.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-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
deleted file mode 100644
index 6210a91..0000000
--- a/utils/supabase/server.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-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.
- }
- },
- },
- }
- );
-}