Refactor to static markdown (remove Supabase backend) + self-host build
build-and-publish / build (push) Successful in 11s

Posts now read from data/posts/*.md via gray-matter + remark; authors derived
from posts. Removes admin dashboard, comments, subscriptions, and all Supabase
usage. Adds Dockerfile (Next.js standalone) + Gitea Actions CI. output: standalone.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-22 13:11:56 -05:00
co-authored by Claude Opus 4.8
parent b54ad064ff
commit ba054ab2eb
41 changed files with 214 additions and 2704 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules
.next
.git
.env
.env.local
Dockerfile
.dockerignore
README.md
node-*
+16
View File
@@ -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 }}
+24
View File
@@ -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"]
@@ -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 (
<div>
<h1 className="text-3xl font-bold mb-8">Edit Author</h1>
<div className="bg-white rounded-lg shadow-sm p-6 max-w-2xl">
<form action={updateAuthor} className="space-y-4">
<input type="hidden" name="originalName" value={author.name} />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name
</label>
<input
type="text"
name="name"
defaultValue={author.name}
required
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Bio
</label>
<textarea
name="bio"
rows={4}
defaultValue={author.bio}
required
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Profile Picture URL
</label>
<input
type="text"
name="pfp_link"
defaultValue={author.pfp_link || ""}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
X (Twitter) Link
</label>
<input
type="text"
name="x_link"
defaultValue={author.x_link || ""}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Facebook Link
</label>
<input
type="text"
name="fb_link"
defaultValue={author.fb_link || ""}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Instagram Link
</label>
<input
type="text"
name="insta_link"
defaultValue={author.insta_link || ""}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div className="flex gap-4 pt-4">
<button
type="submit"
className="bg-accent text-white px-6 py-2 rounded-md hover:bg-accent-dark"
>
Update Author
</button>
<a
href="/admin/authors"
className="px-6 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</a>
</div>
</form>
</div>
</div>
);
}
-71
View File
@@ -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");
}
@@ -1,89 +0,0 @@
import { createAuthor } from "../actions";
export default function NewAuthorPage() {
return (
<div>
<h1 className="text-3xl font-bold mb-8">New Author</h1>
<div className="bg-white rounded-lg shadow-sm p-6 max-w-2xl">
<form action={createAuthor} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name
</label>
<input
type="text"
name="name"
required
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Bio
</label>
<textarea
name="bio"
rows={4}
required
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Profile Picture URL
</label>
<input
type="text"
name="pfp_link"
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
X (Twitter) Link
</label>
<input
type="text"
name="x_link"
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Facebook Link
</label>
<input
type="text"
name="fb_link"
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Instagram Link
</label>
<input
type="text"
name="insta_link"
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div className="flex gap-4 pt-4">
<button
type="submit"
className="bg-accent text-white px-6 py-2 rounded-md hover:bg-accent-dark"
>
Create Author
</button>
<a
href="/admin/authors"
className="px-6 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</a>
</div>
</form>
</div>
</div>
);
}
-100
View File
@@ -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 (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Authors</h1>
<Link
href="/admin/authors/new"
className="bg-accent text-white px-4 py-2 rounded-md hover:bg-accent-dark"
>
New Author
</Link>
</div>
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Bio
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Links
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{authors?.map((author) => (
<tr key={author.name} className="hover:bg-gray-50">
<td className="px-6 py-4 font-medium">{author.name}</td>
<td className="px-6 py-4">
<p className="text-gray-600 line-clamp-2 max-w-md">
{author.bio}
</p>
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
{author.x_link && (
<span className="text-xs bg-gray-100 px-2 py-1 rounded">
X
</span>
)}
{author.fb_link && (
<span className="text-xs bg-gray-100 px-2 py-1 rounded">
FB
</span>
)}
{author.insta_link && (
<span className="text-xs bg-gray-100 px-2 py-1 rounded">
IG
</span>
)}
</div>
</td>
<td className="px-6 py-4 text-right space-x-2">
<Link
href={`/admin/authors/${encodeURIComponent(author.name)}/edit`}
className="text-sm text-accent hover:text-accent-dark"
>
Edit
</Link>
<form
action={deleteAuthor.bind(null, author.name)}
className="inline"
>
<button
type="submit"
className="text-sm text-red-600 hover:text-red-800"
>
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{(!authors || authors.length === 0) && (
<p className="text-gray-500 text-center py-8">No authors found.</p>
)}
</div>
</div>
);
}
-16
View File
@@ -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");
}
-78
View File
@@ -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 (
<div>
<h1 className="text-3xl font-bold mb-8">Comments</h1>
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Author
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Comment
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Post
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{comments?.map((comment) => (
<tr key={comment.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<p className="font-medium">{comment.name}</p>
<p className="text-gray-500 text-sm">{comment.email}</p>
</td>
<td className="px-6 py-4">
<p className="text-gray-700 line-clamp-2 max-w-md">
{comment.comment}
</p>
</td>
<td className="px-6 py-4 text-gray-600 text-sm">
{comment.post_id}
</td>
<td className="px-6 py-4 text-gray-600 text-sm">
{new Date(comment.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-right">
<form
action={deleteComment.bind(null, comment.id)}
className="inline"
>
<button
type="submit"
className="text-sm text-red-600 hover:text-red-800"
>
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{(!comments || comments.length === 0) && (
<p className="text-gray-500 text-center py-8">No comments found.</p>
)}
</div>
</div>
);
}
@@ -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<string, number> = {
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 (
<aside className="w-64 bg-gray-900 text-white min-h-screen flex flex-col">
<div className="p-6 border-b border-gray-700">
<Link href="/" className="text-lg font-bold">
Confessions of Grace
</Link>
<p className="text-gray-400 text-sm mt-1">Admin Panel</p>
</div>
<nav className="flex-1 p-4">
<ul className="space-y-1">
{navItems
.filter((item) => userLevel >= (roleHierarchy[item.minRole] || 0))
.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/admin" && pathname.startsWith(item.href));
return (
<li key={item.href}>
<Link
href={item.href}
className={`block px-4 py-2 rounded-md transition-colors ${
isActive
? "bg-gray-700 text-white"
: "text-gray-300 hover:bg-gray-800 hover:text-white"
}`}
>
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
<div className="p-4 border-t border-gray-700">
<p className="text-gray-400 text-xs mb-2 capitalize">
Role: {role.replace("_", " ")}
</p>
<button
onClick={handleLogout}
className="w-full text-left px-4 py-2 text-gray-300 hover:bg-gray-800 hover:text-white rounded-md transition-colors"
>
Sign Out
</button>
</div>
</aside>
);
}
@@ -1,57 +0,0 @@
"use client";
import { useState } from "react";
interface DeleteConfirmDialogProps {
title: string;
message: string;
onConfirm: () => Promise<void>;
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 (
<>
<span onClick={() => setOpen(true)}>{children}</span>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-bold mb-2">{title}</h3>
<p className="text-gray-600 mb-6">{message}</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setOpen(false)}
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
disabled={loading}
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={loading}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50"
>
{loading ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
)}
</>
);
}
@@ -1,202 +0,0 @@
"use client";
import { useState } from "react";
interface PostEditorProps {
action: (formData: FormData) => Promise<void>;
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 (
<form action={action} className="space-y-6">
{/* Slug / ID */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Slug (URL ID)
</label>
<input
type="text"
name="id"
defaultValue={initialData?.id || ""}
readOnly={isEdit}
required
placeholder="my-post-slug"
className={`w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent ${isEdit ? "bg-gray-100" : ""}`}
/>
</div>
{/* Title */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Title
</label>
<input
type="text"
name="title"
defaultValue={initialData?.title || ""}
required
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{/* Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Date
</label>
<input
type="date"
name="date"
defaultValue={
initialData?.date
? new Date(initialData.date).toISOString().split("T")[0]
: new Date().toISOString().split("T")[0]
}
required
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{/* Author */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Author
</label>
<select
name="author"
defaultValue={initialData?.author || ""}
required
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
>
<option value="">Select an author</option>
{authors.map((a) => (
<option key={a.name} value={a.name}>
{a.name}
</option>
))}
</select>
</div>
{/* Excerpt */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Excerpt
</label>
<textarea
name="excerpt"
rows={2}
defaultValue={initialData?.excerpt || ""}
required
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tags (comma-separated)
</label>
<input
type="text"
name="tags"
defaultValue={initialData?.tags?.join(", ") || ""}
placeholder="theology, books, personal life"
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{/* Cover Image */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Cover Image URL
</label>
<input
type="text"
name="coverImage"
defaultValue={initialData?.coverImage || ""}
placeholder="/images/my-post.png"
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{/* Content (Markdown) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Content (Markdown)
</label>
<textarea
name="content"
rows={20}
value={content}
onChange={(e) => 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"
/>
</div>
{/* Published Toggle */}
<div className="flex items-center gap-3">
<input
type="hidden"
name="published"
value={published ? "true" : "false"}
/>
<button
type="button"
onClick={() => setPublished(!published)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
published ? "bg-green-500" : "bg-gray-300"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
published ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
<span className="text-sm text-gray-700">
{published ? "Published" : "Draft"}
</span>
</div>
{/* Submit */}
<div className="flex gap-4">
<button
type="submit"
className="bg-accent text-white px-6 py-2 rounded-md hover:bg-accent-dark"
>
{isEdit ? "Update Post" : "Create Post"}
</button>
<a
href="/admin/posts"
className="px-6 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</a>
</div>
</form>
);
}
-36
View File
@@ -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 (
<div className="flex min-h-screen">
<AdminSidebar role={adminUser.role} />
<main className="flex-1 bg-gray-50 p-8 overflow-auto">{children}</main>
</div>
);
}
-131
View File
@@ -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 (
<div>
<h1 className="text-3xl font-bold mb-8">Dashboard</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{stats.map((stat) => (
<Link
key={stat.label}
href={stat.href}
className="bg-white rounded-lg shadow-sm p-6 hover:shadow-md transition-shadow"
>
<p className="text-gray-500 text-sm">{stat.label}</p>
<p className="text-3xl font-bold mt-1">{stat.count}</p>
</Link>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Recent Posts */}
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Recent Posts</h2>
<Link
href="/admin/posts/new"
className="text-sm bg-accent text-white px-3 py-1 rounded-md hover:bg-accent-dark"
>
New Post
</Link>
</div>
{recentPosts && recentPosts.length > 0 ? (
<ul className="divide-y divide-gray-100">
{recentPosts.map((post) => (
<li key={post.id} className="py-3">
<Link
href={`/admin/posts/${post.id}/edit`}
className="hover:text-accent-dark"
>
<span className="font-medium">{post.title}</span>
{!post.published && (
<span className="ml-2 text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded">
Draft
</span>
)}
</Link>
<p className="text-gray-500 text-sm">
{new Date(post.date).toLocaleDateString()}
</p>
</li>
))}
</ul>
) : (
<p className="text-gray-500">No posts yet.</p>
)}
</div>
{/* Recent Comments */}
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Recent Comments</h2>
<Link
href="/admin/comments"
className="text-sm text-accent hover:text-accent-dark"
>
View All
</Link>
</div>
{recentComments && recentComments.length > 0 ? (
<ul className="divide-y divide-gray-100">
{recentComments.map((comment) => (
<li key={comment.id} className="py-3">
<p className="font-medium">{comment.name}</p>
<p className="text-gray-600 text-sm line-clamp-2">
{comment.comment}
</p>
<p className="text-gray-400 text-xs mt-1">
on {comment.post_id} &middot;{" "}
{new Date(comment.created_at).toLocaleDateString()}
</p>
</li>
))}
</ul>
) : (
<p className="text-gray-500">No comments yet.</p>
)}
</div>
</div>
</div>
);
}
@@ -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 (
<div>
<h1 className="text-3xl font-bold mb-8">Edit Post</h1>
<div className="bg-white rounded-lg shadow-sm p-6">
<PostEditor
action={updatePost}
initialData={{
id: post.id,
title: post.title,
date: post.date,
excerpt: post.excerpt,
content: post.content,
author: post.author,
tags: post.tags || [],
coverImage: post.cover_image,
published: post.published,
}}
authors={authors || []}
isEdit
/>
</div>
</div>
);
}
-119
View File
@@ -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");
}
-21
View File
@@ -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 (
<div>
<h1 className="text-3xl font-bold mb-8">New Post</h1>
<div className="bg-white rounded-lg shadow-sm p-6">
<PostEditor action={createPost} authors={authors || []} />
</div>
</div>
);
}
-99
View File
@@ -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 (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Posts</h1>
<Link
href="/admin/posts/new"
className="bg-accent text-white px-4 py-2 rounded-md hover:bg-accent-dark"
>
New Post
</Link>
</div>
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Title
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Author
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{posts?.map((post) => (
<tr key={post.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<Link
href={`/admin/posts/${post.id}/edit`}
className="text-gray-900 font-medium hover:text-accent-dark"
>
{post.title}
</Link>
<p className="text-gray-500 text-sm">{post.id}</p>
</td>
<td className="px-6 py-4 text-gray-600">{post.author}</td>
<td className="px-6 py-4 text-gray-600">
{new Date(post.date).toLocaleDateString()}
</td>
<td className="px-6 py-4">
{post.published ? (
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">
Published
</span>
) : (
<span className="text-xs bg-yellow-100 text-yellow-700 px-2 py-1 rounded">
Draft
</span>
)}
</td>
<td className="px-6 py-4 text-right space-x-2">
<Link
href={`/admin/posts/${post.id}/edit`}
className="text-sm text-accent hover:text-accent-dark"
>
Edit
</Link>
<form action={deletePost.bind(null, post.id)} className="inline">
<button
type="submit"
className="text-sm text-red-600 hover:text-red-800"
>
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{(!posts || posts.length === 0) && (
<p className="text-gray-500 text-center py-8">No posts found.</p>
)}
</div>
</div>
);
}
@@ -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 (
<button
onClick={handleExport}
className="bg-gray-800 text-white px-4 py-2 rounded-md hover:bg-gray-700"
>
Export CSV
</button>
);
}
@@ -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");
}
@@ -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 (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Subscriptions</h1>
<ExportButton subscriptions={subscriptions || []} />
</div>
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Subscribed
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{subscriptions?.map((sub) => (
<tr key={sub.id} className="hover:bg-gray-50">
<td className="px-6 py-4 font-medium">{sub.email}</td>
<td className="px-6 py-4 text-gray-600">
{new Date(sub.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-right">
<form
action={deleteSubscription.bind(null, sub.id)}
className="inline"
>
<button
type="submit"
className="text-sm text-red-600 hover:text-red-800"
>
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
{(!subscriptions || subscriptions.length === 0) && (
<p className="text-gray-500 text-center py-8">
No subscriptions found.
</p>
)}
</div>
</div>
);
}
-79
View File
@@ -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");
}
-198
View File
@@ -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 (
<div>
<h1 className="text-3xl font-bold mb-8">Admin Users</h1>
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Added
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{adminUsers?.map((adminUser) => (
<tr key={adminUser.id} className="hover:bg-gray-50">
<td className="px-6 py-4 font-medium">{adminUser.email}</td>
<td className="px-6 py-4">
<form className="inline">
<select
defaultValue={adminUser.role}
onChange={async (e) => {
"use server";
}}
className="text-sm border border-gray-300 rounded px-2 py-1"
disabled={adminUser.user_id === user.id}
>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
<option value="super_admin">Super Admin</option>
</select>
</form>
</td>
<td className="px-6 py-4 text-gray-600 text-sm">
{new Date(adminUser.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-right">
{adminUser.user_id !== user.id && (
<div className="flex justify-end gap-2">
{(["editor", "admin", "super_admin"] as const)
.filter((r) => r !== adminUser.role)
.map((role) => (
<form
key={role}
action={updateAdminRole.bind(
null,
adminUser.id,
role
)}
className="inline"
>
<button
type="submit"
className="text-xs text-accent hover:text-accent-dark capitalize"
>
Make {role.replace("_", " ")}
</button>
</form>
))}
<form
action={removeAdmin.bind(null, adminUser.id)}
className="inline"
>
<button
type="submit"
className="text-xs text-red-600 hover:text-red-800"
>
Remove
</button>
</form>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
{(!adminUsers || adminUsers.length === 0) && (
<p className="text-gray-500 text-center py-8">
No admin users found.
</p>
)}
</div>
<div className="mt-8 bg-white rounded-lg shadow-sm p-6 max-w-md">
<h2 className="text-xl font-bold mb-4">Add Admin User</h2>
<p className="text-gray-600 text-sm mb-4">
The user must first have a Supabase Auth account. Create one in the
Supabase dashboard, then add their email here.
</p>
<form
action={async (formData: FormData) => {
"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"
>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
name="email"
required
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Auth User ID (UUID)
</label>
<input
type="text"
name="user_id"
required
placeholder="00000000-0000-0000-0000-000000000000"
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role
</label>
<select
name="role"
required
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
<option value="super_admin">Super Admin</option>
</select>
</div>
<button
type="submit"
className="bg-accent text-white px-6 py-2 rounded-md hover:bg-accent-dark"
>
Add Admin
</button>
</form>
</div>
</div>
);
}
-9
View File
@@ -1,9 +0,0 @@
export const dynamic = "force-dynamic";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
-119
View File
@@ -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<string | null>(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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<h1 className="text-2xl font-bold text-center mb-6">Admin Login</h1>
<p className="text-gray-600 text-center mb-8">
Sign in to the Confessions of Grace admin panel.
</p>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-6">
{error}
</div>
)}
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => 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
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => 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
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-accent text-white py-2 px-4 rounded-md hover:bg-accent-dark disabled:opacity-50 transition-colors"
>
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
</div>
</div>
);
}
-81
View File
@@ -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 }
);
}
}
-70
View File
@@ -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 }
);
}
}
+10 -88
View File
@@ -1,105 +1,27 @@
'use client'; import React from 'react';
import React, { useEffect, useState } from 'react';
import { createClient } from '@/utils/supabase/client';
const supabase = createClient();
import { PostMetadata } from '@/types'; import { PostMetadata } from '@/types';
interface AuthorProfile {
name: string;
bio: string;
x_link?: string;
fb_link?: string;
insta_link?: string;
pfp_link?: string;
}
interface AuthorProfileProps { interface AuthorProfileProps {
author: string; author: string;
posts: PostMetadata[]; posts: PostMetadata[];
} }
export default function AuthorProfile({ author, posts }: AuthorProfileProps) { // Static author profile. No bio/social data is available in the markdown
const [authorProfile, setAuthorProfile] = useState<AuthorProfile | null>(null); // source, so we render the author's name and a placeholder avatar only.
export default function AuthorProfile({ author }: AuthorProfileProps) {
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]);
return ( return (
<div className="mb-12 flex flex-col md:flex-row items-start md:items-center gap-6"> <div className="mb-12 flex flex-col md:flex-row items-start md:items-center gap-6">
{/* Profile Picture */} {/* Placeholder avatar */}
{authorProfile?.pfp_link ? ( <div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center text-2xl font-bold text-gray-500">
<img {author.charAt(0).toUpperCase()}
src={authorProfile.pfp_link} </div>
alt={`${authorProfile.name}'s profile picture`}
className="w-24 h-24 rounded-full object-cover shadow-md"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center text-xl font-bold text-gray-500">
?
</div>
)}
<div> <div>
{/* Author Name */} {/* Author Name */}
<h1 className="text-3xl md:text-4xl font-bold mb-2"> <h1 className="text-3xl md:text-4xl font-bold mb-2">
{authorProfile?.name || author} {author}
</h1> </h1>
{/* Author Bio */}
{authorProfile?.bio && (
<p className="text-primary-600 mb-2">{authorProfile.bio}</p>
)}
{/* Social Links */}
<div className="flex gap-4 mt-2">
{authorProfile?.x_link && (
<a
href={authorProfile.x_link}
target="_blank"
rel="noopener noreferrer"
>
<img src="/icons/x.svg" alt="X (Twitter)" className="w-5 h-5" />
</a>
)}
{authorProfile?.fb_link && (
<a
href={authorProfile.fb_link}
target="_blank"
rel="noopener noreferrer"
>
<img src="/icons/facebook.svg" alt="Facebook" className="w-5 h-5" />
</a>
)}
{authorProfile?.insta_link && (
<a
href={authorProfile.insta_link}
target="_blank"
rel="noopener noreferrer"
>
<img src="/icons/instagram.svg" alt="Instagram" className="w-5 h-5" />
</a>
)}
</div>
</div> </div>
</div> </div>
); );
} }
+15 -7
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import PostCard from '@/components/PostCard'; import PostCard from '@/components/PostCard';
import { getPostsByAuthor } from '@/lib/posts'; import { getPostsByAuthor } from '@/lib/posts';
import { getAllAuthors } from '@/lib/authors';
import { PostMetadata } from '@/types'; import { PostMetadata } from '@/types';
import { generateMetadata as createMetadata } from '@/components/Metadata'; import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
@@ -10,13 +11,19 @@ interface PageProps {
params: Promise<{ author: string }>; 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<Metadata> { export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { author } = await params; const { author } = await params;
const decodedAuthor = decodeURIComponent(author);
return createMetadata({ return createMetadata({
title: `${author} | Author`, title: `${decodedAuthor} | Author`,
description: `Posts authored by ${author} on Confessions of Grace.`, description: `Posts authored by ${decodedAuthor} on Confessions of Grace.`,
url: `https://confessionsofgrace.com/authors/${author}`, url: `https://confessionsofgrace.com/authors/${author}`,
type: 'website' type: 'website'
}); });
@@ -28,15 +35,16 @@ async function getPostsByAuthorData(author: string): Promise<PostMetadata[]> {
export default async function AuthorPage({ params }: PageProps) { export default async function AuthorPage({ params }: PageProps) {
const { author } = await params; const { author } = await params;
const posts = await getPostsByAuthorData(author); const decodedAuthor = decodeURIComponent(author);
const posts = await getPostsByAuthorData(decodedAuthor);
return ( return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<AuthorProfile author={author} posts={posts} /> <AuthorProfile author={decodedAuthor} posts={posts} />
{/* Posts */} {/* Posts */}
<p className="text-lg text-primary-600 mb-6"> <p className="text-lg text-primary-600 mb-6">
{posts.length} {posts.length === 1 ? 'post' : 'posts'} authored by "{author}" {posts.length} {posts.length === 1 ? 'post' : 'posts'} authored by &quot;{decodedAuthor}&quot;
</p> </p>
{posts.length > 0 ? ( {posts.length > 0 ? (
@@ -52,4 +60,4 @@ export default async function AuthorPage({ params }: PageProps) {
)} )}
</div> </div>
); );
} }
+9 -44
View File
@@ -1,21 +1,9 @@
export const dynamic = 'force-dynamic';
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import { getAllAuthors } from '@/lib/authors';
import { createClient } from '@/utils/supabase/server';
import { generateMetadata as createMetadata } from '@/components/Metadata'; import { generateMetadata as createMetadata } from '@/components/Metadata';
import type { Metadata } from 'next'; 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<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return createMetadata({ return createMetadata({
title: 'Authors', title: 'Authors',
@@ -25,27 +13,8 @@ export async function generateMetadata(): Promise<Metadata> {
}); });
} }
async function getAuthors(): Promise<AuthorProfile[]> {
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() { export default async function AuthorsPage() {
const authors = await getAuthors(); const authors = await getAllAuthors();
return ( return (
<div className="max-w-5xl mx-auto px-4"> <div className="max-w-5xl mx-auto px-4">
@@ -55,20 +24,16 @@ export default async function AuthorsPage() {
{authors.map((author) => ( {authors.map((author) => (
<Link <Link
key={author.name} key={author.name}
href={`/authors/${author.name}`} href={`/authors/${encodeURIComponent(author.slug)}`}
className="bg-white rounded-lg shadow-md p-5 flex flex-col items-center hover:shadow-lg transition-shadow" className="bg-white rounded-lg shadow-md p-5 flex flex-col items-center hover:shadow-lg transition-shadow"
> >
<div className="w-24 h-24 mb-4 relative"> <div className="w-24 h-24 mb-4 rounded-full bg-gray-200 flex items-center justify-center text-2xl font-bold text-gray-500">
<Image {author.name.charAt(0).toUpperCase()}
src={author.pfp_link || '/images/authors/default.jpg'}
alt={`${author.name}'s profile`}
fill
className="rounded-full object-cover"
sizes="96px"
/>
</div> </div>
<h2 className="text-lg font-semibold">{author.name}</h2> <h2 className="text-lg font-semibold">{author.name}</h2>
{/* You can include bio or social icons here */} <p className="text-sm text-primary-500 mt-1">
{author.postCount} {author.postCount === 1 ? 'post' : 'posts'}
</p>
</Link> </Link>
))} ))}
</div> </div>
@@ -80,4 +45,4 @@ export default async function AuthorsPage() {
</div> </div>
</div> </div>
); );
} }
-3
View File
@@ -7,7 +7,6 @@ import { format } from "date-fns";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
import CommentSection from "@/components/CommentSection";
import { generateMetadata as createMetadata } from "@/components/Metadata"; import { generateMetadata as createMetadata } from "@/components/Metadata";
import type { Metadata } from "next"; import type { Metadata } from "next";
@@ -136,8 +135,6 @@ export default async function PostPage({ params }: PageProps) {
</div> </div>
</div> </div>
<CommentSection postId={post.id} />
<div className="mt-12 pt-6 border-t border-primary-200"> <div className="mt-12 pt-6 border-t border-primary-200">
<Link <Link
href="/public" href="/public"
-268
View File
@@ -1,268 +0,0 @@
"use client"
import { createClient } from '@/utils/supabase/client';
const supabase = createClient();
import React, { useState, useEffect } from 'react';
interface CommentFormProps {
postId: string;
}
interface Comment {
id: number; // Supabase tables typically have an ID
name: string;
comment: string;
created_at: string; // Use the Supabase column name
}
const CommentSection: React.FC<CommentFormProps> = ({ 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<string | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
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 (
<div className="mt-12 pt-6 border-t border-primary-200">
<h3 className="text-2xl font-bold mb-6">Leave a Comment</h3>
{isSubmitted && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md mb-6">
Your comment has been submitted. Thank you!
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-6">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="name" className="block text-primary-700 mb-1">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
required
onFocus={() => {
if (typeof window !== 'undefined') {
const savedName = localStorage.getItem('name');
if (savedName) setName(savedName);
}
}}
/>
</div>
<div>
<label htmlFor="email" className="block text-primary-700 mb-1">
Email <span className="text-red-500">*</span>
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
required
onFocus={() => {
if (typeof window !== 'undefined') {
const savedEmail = localStorage.getItem('email');
if (savedEmail) setEmail(savedEmail);
}
}}
/>
</div>
</div>
<div>
<label htmlFor="comment" className="block text-primary-700 mb-1">
Comment <span className="text-red-500">*</span>
</label>
<textarea
id="comment"
rows={6}
value={comment}
onChange={(e) => setComment(e.target.value)}
className="w-full px-4 py-2 border border-primary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
required
></textarea>
</div>
{/* The save info checkbox logic can remain as it interacts with localStorage */}
{/* <div className="flex items-center">
<input
type="checkbox"
id="save-info"
className="h-4 w-4 text-accent border-primary-300 rounded focus:ring-accent"
checked={typeof window !== 'undefined' && localStorage.getItem('saveInfo') === 'true'}
onChange={(e) => {
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');
}
}
}}
/>
<label htmlFor="save-info" className="ml-2 block text-sm text-primary-600">
Save my name and email for the next time I comment
</label>
</div> */}
<button
type="submit"
className="button"
disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Post Comment'}
</button>
</form>
<div className="mt-12">
<h3 className="text-xl font-bold mb-6">Comments ({comments.length})</h3>
{isLoadingComments ? (
<p>Loading comments...</p>
) : comments.length === 0 ? (
<p>No comments yet. Be the first to leave one!</p>
) : (
<div className="space-y-6">
{comments.map((comment) => (
<div
// Use a more stable key than createdAt if possible, like comment.id from Supabase
key={comment.id || comment.created_at}
className="bg-white p-6 rounded-md shadow-sm border border-primary-200"
>
<div className="flex justify-between items-start mb-4">
<div>
<h4 className="font-bold">{comment.name}</h4>
<p className="text-sm text-primary-500">
{new Date(comment.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
<p className="text-primary-700">{comment.comment}</p>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default CommentSection;
+7 -35
View File
@@ -8,6 +8,9 @@ interface SubscribeFormProps {
className?: string; 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<SubscribeFormProps> = ({ const SubscribeForm: React.FC<SubscribeFormProps> = ({
placeholder = 'Your email', placeholder = 'Your email',
buttonLabel = 'Subscribe', buttonLabel = 'Subscribe',
@@ -15,40 +18,10 @@ const SubscribeForm: React.FC<SubscribeFormProps> = ({
}) => { }) => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [loading, setLoading] = useState(false);
const handleSubscribe = async (e: React.FormEvent) => { const handleSubscribe = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setMessage('Subscriptions are currently unavailable.');
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);
}
}; };
return ( return (
@@ -65,13 +38,12 @@ const SubscribeForm: React.FC<SubscribeFormProps> = ({
<button <button
type="submit" type="submit"
className="button w-full" className="button w-full"
disabled={loading}
> >
{loading ? 'Subscribing...' : buttonLabel} {buttonLabel}
</button> </button>
{message && <p className="text-sm text-primary-700">{message}</p>} {message && <p className="text-sm text-primary-700">{message}</p>}
</form> </form>
); );
}; };
export default SubscribeForm; export default SubscribeForm;
+57
View File
@@ -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<AuthorSummary[]> {
const posts = await getSortedPostsData();
const counts = new Map<string, number>();
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<AuthorWithPosts | null> {
const posts = await getPostsByAuthor(name);
if (posts.length === 0) {
return null;
}
return {
name,
slug: name,
postCount: posts.length,
posts,
};
}
+66 -97
View File
@@ -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"; import { PostData, PostMetadata } from "@/types";
export async function getSortedPostsData(): Promise<PostMetadata[]> { const postsDirectory = path.join(process.cwd(), "data/posts");
const supabase = await createClient();
const { data, error } = await supabase interface PostFrontmatter {
.from("posts") title: string;
.select("id, title, date, excerpt, author, tags, cover_image") date: string;
.eq("published", true) author: string;
.order("date", { ascending: false }); excerpt: string;
tags?: string[];
coverImage?: string;
}
if (error) { function getPostIds(): string[] {
console.error("Error fetching posts:", error); if (!fs.existsSync(postsDirectory)) {
return []; return [];
} }
return fs
.readdirSync(postsDirectory)
.filter((fileName) => fileName.endsWith(".md"))
.map((fileName) => fileName.replace(/\.md$/, ""));
}
return (data || []).map((post) => ({ function readPostMetadata(id: string): PostMetadata {
id: post.id, const fullPath = path.join(postsDirectory, `${id}.md`);
title: post.title, const fileContents = fs.readFileSync(fullPath, "utf8");
date: post.date, const { data } = matter(fileContents);
excerpt: post.excerpt, const frontmatter = data as PostFrontmatter;
author: post.author,
tags: post.tags || [], return {
coverImage: post.cover_image || undefined, 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<PostMetadata[]> {
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 } }[]> { export async function getAllPostIds(): Promise<{ params: { id: string } }[]> {
const supabase = await createClient(); return getPostIds().map((id) => ({
params: { id },
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<PostData> { export async function getPostData(id: string): Promise<PostData> {
const supabase = await createClient(); const fullPath = path.join(postsDirectory, `${id}.md`);
const { data, error } = await supabase if (!fs.existsSync(fullPath)) {
.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}`); 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 { return {
id: data.id, id,
title: data.title, title: frontmatter.title,
date: data.date, date: frontmatter.date,
excerpt: data.excerpt, excerpt: frontmatter.excerpt,
content: data.content_html, content: contentHtml,
author: data.author, author: frontmatter.author,
tags: data.tags || [], tags: frontmatter.tags || [],
coverImage: data.cover_image || undefined, coverImage: frontmatter.coverImage || undefined,
}; };
} }
export async function getPostsByTag(tag: string): Promise<PostMetadata[]> { export async function getPostsByTag(tag: string): Promise<PostMetadata[]> {
const supabase = await createClient(); const posts = await getSortedPostsData();
return posts.filter((post) => post.tags.includes(tag));
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( export async function getPostsByAuthor(
author: string author: string,
): Promise<PostMetadata[]> { ): Promise<PostMetadata[]> {
const supabase = await createClient(); const posts = await getSortedPostsData();
return posts.filter((post) => post.author === author);
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,
}));
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: "standalone",
}; };
export default nextConfig; export default nextConfig;
-19
View File
@@ -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)$).*)",
],
};
-78
View File
@@ -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);
-8
View File
@@ -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!
);
}
-78
View File
@@ -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;
}
-28
View File
@@ -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.
}
},
},
}
);
}