Archived
Refactor to static markdown (remove Supabase backend) + self-host build
build-and-publish / build (push) Successful in 11s
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:
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
README.md
|
||||||
|
node-*
|
||||||
@@ -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
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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} ·{" "}
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default function AdminLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +1,26 @@
|
|||||||
'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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 "{decodedAuthor}"
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{posts.length > 0 ? (
|
{posts.length > 0 ? (
|
||||||
|
|||||||
+8
-43
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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,9 +38,8 @@ 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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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)$).*)",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
@@ -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!
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user