Archived
added supabase
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { updateAuthor } from "../../actions";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ name: string }>;
|
||||
}
|
||||
|
||||
export default async function EditAuthorPage({ params }: PageProps) {
|
||||
const { name } = await params;
|
||||
const decodedName = decodeURIComponent(name);
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: author, error } = await supabase
|
||||
.from("authors")
|
||||
.select("name, bio, x_link, fb_link, insta_link, pfp_link")
|
||||
.eq("name", decodedName)
|
||||
.single();
|
||||
|
||||
if (error || !author) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use server";
|
||||
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export async function createAuthor(formData: FormData) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const name = formData.get("name") as string;
|
||||
const bio = formData.get("bio") as string;
|
||||
const x_link = (formData.get("x_link") as string) || null;
|
||||
const fb_link = (formData.get("fb_link") as string) || null;
|
||||
const insta_link = (formData.get("insta_link") as string) || null;
|
||||
const pfp_link = (formData.get("pfp_link") as string) || null;
|
||||
|
||||
const { error } = await supabase.from("authors").insert({
|
||||
name,
|
||||
bio,
|
||||
x_link,
|
||||
fb_link,
|
||||
insta_link,
|
||||
pfp_link,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to create author: ${error.message}`);
|
||||
}
|
||||
|
||||
revalidatePath("/authors");
|
||||
revalidatePath("/admin/authors");
|
||||
redirect("/admin/authors");
|
||||
}
|
||||
|
||||
export async function updateAuthor(formData: FormData) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const originalName = formData.get("originalName") as string;
|
||||
const name = formData.get("name") as string;
|
||||
const bio = formData.get("bio") as string;
|
||||
const x_link = (formData.get("x_link") as string) || null;
|
||||
const fb_link = (formData.get("fb_link") as string) || null;
|
||||
const insta_link = (formData.get("insta_link") as string) || null;
|
||||
const pfp_link = (formData.get("pfp_link") as string) || null;
|
||||
|
||||
const { error } = await supabase
|
||||
.from("authors")
|
||||
.update({ name, bio, x_link, fb_link, insta_link, pfp_link })
|
||||
.eq("name", originalName);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to update author: ${error.message}`);
|
||||
}
|
||||
|
||||
revalidatePath("/authors");
|
||||
revalidatePath("/admin/authors");
|
||||
redirect("/admin/authors");
|
||||
}
|
||||
|
||||
export async function deleteAuthor(name: string) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { error } = await supabase.from("authors").delete().eq("name", name);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to delete author: ${error.message}`);
|
||||
}
|
||||
|
||||
revalidatePath("/authors");
|
||||
revalidatePath("/admin/authors");
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import Link from "next/link";
|
||||
import { deleteAuthor } from "./actions";
|
||||
|
||||
export default async function AdminAuthorsPage() {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: authors } = await supabase
|
||||
.from("authors")
|
||||
.select("name, bio, x_link, fb_link, insta_link, pfp_link")
|
||||
.order("name");
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use server";
|
||||
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function deleteComment(id: number) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { error } = await supabase.from("comments").delete().eq("id", id);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to delete comment: ${error.message}`);
|
||||
}
|
||||
|
||||
revalidatePath("/admin/comments");
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { deleteComment } from "./actions";
|
||||
|
||||
export default async function AdminCommentsPage() {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: comments } = await supabase
|
||||
.from("comments")
|
||||
.select("id, name, email, comment, post_id, created_at")
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { createClient } from "@/utils/supabase/client";
|
||||
|
||||
interface AdminSidebarProps {
|
||||
role: string;
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ label: "Dashboard", href: "/admin", minRole: "editor" },
|
||||
{ label: "Posts", href: "/admin/posts", minRole: "editor" },
|
||||
{ label: "Comments", href: "/admin/comments", minRole: "editor" },
|
||||
{ label: "Subscriptions", href: "/admin/subscriptions", minRole: "admin" },
|
||||
{ label: "Authors", href: "/admin/authors", minRole: "admin" },
|
||||
{ label: "Admin Users", href: "/admin/users", minRole: "super_admin" },
|
||||
];
|
||||
|
||||
const roleHierarchy: Record<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import AdminSidebar from "./components/AdminSidebar";
|
||||
|
||||
export default async function AdminDashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/admin/login");
|
||||
}
|
||||
|
||||
const { data: adminUser } = await supabase
|
||||
.from("admin_users")
|
||||
.select("role")
|
||||
.eq("user_id", user.id)
|
||||
.single();
|
||||
|
||||
if (!adminUser) {
|
||||
redirect("/admin/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<AdminSidebar role={adminUser.role} />
|
||||
<main className="flex-1 bg-gray-50 p-8 overflow-auto">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function AdminDashboardPage() {
|
||||
const supabase = await createClient();
|
||||
|
||||
const [postsRes, commentsRes, subsRes, authorsRes] = await Promise.all([
|
||||
supabase.from("posts").select("id", { count: "exact", head: true }),
|
||||
supabase.from("comments").select("id", { count: "exact", head: true }),
|
||||
supabase.from("subscriptions").select("id", { count: "exact", head: true }),
|
||||
supabase.from("authors").select("name", { count: "exact", head: true }),
|
||||
]);
|
||||
|
||||
const stats = [
|
||||
{ label: "Posts", count: postsRes.count || 0, href: "/admin/posts" },
|
||||
{
|
||||
label: "Comments",
|
||||
count: commentsRes.count || 0,
|
||||
href: "/admin/comments",
|
||||
},
|
||||
{
|
||||
label: "Subscribers",
|
||||
count: subsRes.count || 0,
|
||||
href: "/admin/subscriptions",
|
||||
},
|
||||
{ label: "Authors", count: authorsRes.count || 0, href: "/admin/authors" },
|
||||
];
|
||||
|
||||
// Recent posts
|
||||
const { data: recentPosts } = await supabase
|
||||
.from("posts")
|
||||
.select("id, title, date, published")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(5);
|
||||
|
||||
// Recent comments
|
||||
const { data: recentComments } = await supabase
|
||||
.from("comments")
|
||||
.select("id, name, comment, post_id, created_at")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(5);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import PostEditor from "../../../components/PostEditor";
|
||||
import { updatePost } from "../../actions";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function EditPostPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: post, error } = await supabase
|
||||
.from("posts")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error || !post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { data: authors } = await supabase
|
||||
.from("authors")
|
||||
.select("name")
|
||||
.order("name");
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use server";
|
||||
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { remark } from "remark";
|
||||
import html from "remark-html";
|
||||
|
||||
export async function createPost(formData: FormData) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const id = formData.get("id") as string;
|
||||
const title = formData.get("title") as string;
|
||||
const date = formData.get("date") as string;
|
||||
const excerpt = formData.get("excerpt") as string;
|
||||
const content = formData.get("content") as string;
|
||||
const author = formData.get("author") as string;
|
||||
const tagsRaw = formData.get("tags") as string;
|
||||
const coverImage = (formData.get("coverImage") as string) || null;
|
||||
const published = formData.get("published") === "true";
|
||||
|
||||
const tags = tagsRaw
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Render markdown to HTML
|
||||
const processedContent = await remark()
|
||||
.use(html, { sanitize: false })
|
||||
.process(content);
|
||||
const contentHtml = processedContent.toString();
|
||||
|
||||
const { error } = await supabase.from("posts").insert({
|
||||
id,
|
||||
title,
|
||||
date: new Date(date).toISOString(),
|
||||
excerpt,
|
||||
content,
|
||||
content_html: contentHtml,
|
||||
author,
|
||||
tags,
|
||||
cover_image: coverImage,
|
||||
published,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to create post: ${error.message}`);
|
||||
}
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath("/posts");
|
||||
revalidatePath("/admin/posts");
|
||||
redirect("/admin/posts");
|
||||
}
|
||||
|
||||
export async function updatePost(formData: FormData) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const id = formData.get("id") as string;
|
||||
const title = formData.get("title") as string;
|
||||
const date = formData.get("date") as string;
|
||||
const excerpt = formData.get("excerpt") as string;
|
||||
const content = formData.get("content") as string;
|
||||
const author = formData.get("author") as string;
|
||||
const tagsRaw = formData.get("tags") as string;
|
||||
const coverImage = (formData.get("coverImage") as string) || null;
|
||||
const published = formData.get("published") === "true";
|
||||
|
||||
const tags = tagsRaw
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Render markdown to HTML
|
||||
const processedContent = await remark()
|
||||
.use(html, { sanitize: false })
|
||||
.process(content);
|
||||
const contentHtml = processedContent.toString();
|
||||
|
||||
const { error } = await supabase
|
||||
.from("posts")
|
||||
.update({
|
||||
title,
|
||||
date: new Date(date).toISOString(),
|
||||
excerpt,
|
||||
content,
|
||||
content_html: contentHtml,
|
||||
author,
|
||||
tags,
|
||||
cover_image: coverImage,
|
||||
published,
|
||||
})
|
||||
.eq("id", id);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to update post: ${error.message}`);
|
||||
}
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath("/posts");
|
||||
revalidatePath(`/posts/${id}`);
|
||||
revalidatePath("/admin/posts");
|
||||
redirect("/admin/posts");
|
||||
}
|
||||
|
||||
export async function deletePost(id: string) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { error } = await supabase.from("posts").delete().eq("id", id);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to delete post: ${error.message}`);
|
||||
}
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath("/posts");
|
||||
revalidatePath("/admin/posts");
|
||||
redirect("/admin/posts");
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import PostEditor from "../../components/PostEditor";
|
||||
import { createPost } from "../actions";
|
||||
|
||||
export default async function NewPostPage() {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: authors } = await supabase
|
||||
.from("authors")
|
||||
.select("name")
|
||||
.order("name");
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import Link from "next/link";
|
||||
import { deletePost } from "./actions";
|
||||
|
||||
export default async function AdminPostsPage() {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: posts } = await supabase
|
||||
.from("posts")
|
||||
.select("id, title, date, author, published, tags")
|
||||
.order("date", { ascending: false });
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
interface ExportButtonProps {
|
||||
subscriptions: { email: string; created_at: string }[];
|
||||
}
|
||||
|
||||
export default function ExportButton({ subscriptions }: ExportButtonProps) {
|
||||
const handleExport = () => {
|
||||
const csv = [
|
||||
"email,subscribed_date",
|
||||
...subscriptions.map(
|
||||
(s) =>
|
||||
`${s.email},${new Date(s.created_at).toISOString().split("T")[0]}`
|
||||
),
|
||||
].join("\n");
|
||||
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "subscriptions.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="bg-gray-800 text-white px-4 py-2 rounded-md hover:bg-gray-700"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use server";
|
||||
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function deleteSubscription(id: number) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { error } = await supabase
|
||||
.from("subscriptions")
|
||||
.delete()
|
||||
.eq("id", id);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to delete subscription: ${error.message}`);
|
||||
}
|
||||
|
||||
revalidatePath("/admin/subscriptions");
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { deleteSubscription } from "./actions";
|
||||
import ExportButton from "./ExportButton";
|
||||
|
||||
export default async function AdminSubscriptionsPage() {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: subscriptions } = await supabase
|
||||
.from("subscriptions")
|
||||
.select("id, email, created_at")
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use server";
|
||||
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function updateAdminRole(
|
||||
adminId: string,
|
||||
newRole: "super_admin" | "admin" | "editor"
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { error } = await supabase
|
||||
.from("admin_users")
|
||||
.update({ role: newRole })
|
||||
.eq("id", adminId);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to update role: ${error.message}`);
|
||||
}
|
||||
|
||||
revalidatePath("/admin/users");
|
||||
}
|
||||
|
||||
export async function removeAdmin(adminId: string) {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { error } = await supabase
|
||||
.from("admin_users")
|
||||
.delete()
|
||||
.eq("id", adminId);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to remove admin: ${error.message}`);
|
||||
}
|
||||
|
||||
revalidatePath("/admin/users");
|
||||
}
|
||||
|
||||
export async function inviteAdmin(email: string, role: string) {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Check if user exists in auth
|
||||
// Note: This requires admin API access. For now, we just add to admin_users
|
||||
// The user must already have a Supabase Auth account.
|
||||
|
||||
// Look up user by email in admin_users to prevent duplicates
|
||||
const { data: existing } = await supabase
|
||||
.from("admin_users")
|
||||
.select("id")
|
||||
.eq("email", email)
|
||||
.single();
|
||||
|
||||
if (existing) {
|
||||
throw new Error("This email is already an admin.");
|
||||
}
|
||||
|
||||
// We need the user's auth ID. Look them up via the admin_users approach:
|
||||
// The admin must create the auth user first via Supabase dashboard,
|
||||
// then add them here with their user_id.
|
||||
// For a simpler flow, we'll insert with just email and let super_admin
|
||||
// provide the user_id separately.
|
||||
|
||||
const userId = (await supabase.auth.getUser()).data.user?.id;
|
||||
if (!userId) throw new Error("Not authenticated");
|
||||
|
||||
// This is a simplified version - in production you'd use the admin API
|
||||
// to look up or invite the user
|
||||
const { error } = await supabase.from("admin_users").insert({
|
||||
email,
|
||||
role,
|
||||
user_id: "00000000-0000-0000-0000-000000000000", // placeholder - must be updated
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to invite admin: ${error.message}`);
|
||||
}
|
||||
|
||||
revalidatePath("/admin/users");
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { updateAdminRole, removeAdmin } from "./actions";
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Verify current user is super_admin
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) redirect("/admin/login");
|
||||
|
||||
const { data: currentAdmin } = await supabase
|
||||
.from("admin_users")
|
||||
.select("role")
|
||||
.eq("user_id", user.id)
|
||||
.single();
|
||||
|
||||
if (!currentAdmin || currentAdmin.role !== "super_admin") {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const { data: adminUsers } = await supabase
|
||||
.from("admin_users")
|
||||
.select("id, user_id, email, role, created_at")
|
||||
.order("created_at");
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { createClient } from "@/utils/supabase/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<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,68 +0,0 @@
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { NextRequest, NextResponse } from 'next/server'; // Use next/server for Edge runtime
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { name, email, comment, postId } = await req.json();
|
||||
|
||||
if (!name || !email || !comment || !postId) {
|
||||
return NextResponse.json({ message: 'All fields are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Insert data into the 'comments' table
|
||||
const { data, error } = await supabase
|
||||
.from('comments') // Replace 'comments' with your Supabase table name
|
||||
.insert([
|
||||
{
|
||||
name,
|
||||
email,
|
||||
comment,
|
||||
post_id: postId,
|
||||
created_at: new Date().toISOString(), // Supabase often uses ISO strings for timestamps
|
||||
},
|
||||
]);
|
||||
|
||||
if (error) {
|
||||
console.error('Error inserting comment:', error);
|
||||
return NextResponse.json({ message: 'Error submitting comment', error }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Comment submitted successfully', data }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Internal server error during POST:', error);
|
||||
// Catching and returning a 500 for unexpected errors
|
||||
return NextResponse.json({ message: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Get query parameters from the URL
|
||||
const { searchParams } = new URL(req.url);
|
||||
const postId = searchParams.get('postId');
|
||||
|
||||
if (!postId) {
|
||||
return NextResponse.json({ message: 'Post ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch comments for a specific postId, ordered by creation date descending
|
||||
const { data: comments, error } = await supabase
|
||||
.from('comments') // Replace 'comments' with your Supabase table name
|
||||
.select('*')
|
||||
.eq('post_id', postId) // Assuming your Supabase column for post ID is 'post_id'
|
||||
.order('created_at', { ascending: false }); // Assuming your timestamp column is 'created_at'
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching comments:', error);
|
||||
return NextResponse.json({ message: 'Error fetching comments', error }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(comments, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('Internal server error during GET:', error);
|
||||
// Catching and returning a 500 for unexpected errors
|
||||
return NextResponse.json({ message: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { name, email, comment, postId } = await req.json();
|
||||
|
||||
if (!name || !email || !comment || !postId) {
|
||||
return NextResponse.json(
|
||||
{ message: "All fields are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.from("comments").insert([
|
||||
{
|
||||
name,
|
||||
email,
|
||||
comment,
|
||||
post_id: postId,
|
||||
},
|
||||
]);
|
||||
|
||||
if (error) {
|
||||
console.error("Error inserting comment:", error);
|
||||
return NextResponse.json(
|
||||
{ message: "Error submitting comment", error },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Comment submitted successfully", data },
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Internal server error during POST:", error);
|
||||
return NextResponse.json(
|
||||
{ message: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { searchParams } = new URL(req.url);
|
||||
const postId = searchParams.get("postId");
|
||||
|
||||
if (!postId) {
|
||||
return NextResponse.json(
|
||||
{ message: "Post ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data: comments, error } = await supabase
|
||||
.from("comments")
|
||||
.select("*")
|
||||
.eq("post_id", postId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching comments:", error);
|
||||
return NextResponse.json(
|
||||
{ message: "Error fetching comments", error },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(comments, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Internal server error during GET:", error);
|
||||
return NextResponse.json(
|
||||
{ message: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { NextRequest, NextResponse } from 'next/server'; // Use next/server for Edge runtime
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function POST(req: NextRequest) {
|
||||
// Only allow POST requests - this is handled by exporting the POST function
|
||||
|
||||
const { email } = await req.json(); // Get body from Edge request
|
||||
|
||||
// Check if email was provided
|
||||
if (!email) {
|
||||
return NextResponse.json({ message: 'Email is required.' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /\S+@\S+\.\S+/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json({ message: 'Invalid email address.' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if the email already exists in the 'subscriptions' table
|
||||
const { data: existingEmails, error: selectError } = await supabase
|
||||
.from('subscriptions') // Replace 'subscriptions' with your Supabase table name
|
||||
.select('email')
|
||||
.eq('email', email);
|
||||
|
||||
if (selectError) {
|
||||
console.error('Error checking existing email:', selectError);
|
||||
return NextResponse.json({ message: 'An unexpected error occurred while checking email.' }, { status: 500 });
|
||||
}
|
||||
|
||||
if (existingEmails && existingEmails.length > 0) {
|
||||
return NextResponse.json({ message: 'Email is already subscribed.' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Save email to the 'subscriptions' table
|
||||
const { data: insertedData, error: insertError } = await supabase
|
||||
.from('subscriptions') // Replace 'subscriptions' with your Supabase table name
|
||||
.insert([
|
||||
{
|
||||
email,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
if (insertError) {
|
||||
console.error('Failed to save subscription:', insertError);
|
||||
return NextResponse.json({ message: 'An unexpected error occurred while saving subscription.' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Respond with success
|
||||
return NextResponse.json({ message: 'Successfully subscribed!' }, { status: 200 });
|
||||
|
||||
} catch (error) {
|
||||
// Log unexpected errors
|
||||
console.error('An unexpected server error occurred:', error);
|
||||
return NextResponse.json({ message: 'An unexpected error occurred.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const { email } = await req.json();
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json(
|
||||
{ message: "Email is required." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const emailRegex = /\S+@\S+\.\S+/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid email address." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: existingEmails, error: selectError } = await supabase
|
||||
.from("subscriptions")
|
||||
.select("email")
|
||||
.eq("email", email);
|
||||
|
||||
if (selectError) {
|
||||
console.error("Error checking existing email:", selectError);
|
||||
return NextResponse.json(
|
||||
{ message: "An unexpected error occurred while checking email." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (existingEmails && existingEmails.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ message: "Email is already subscribed." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from("subscriptions")
|
||||
.insert([{ email }]);
|
||||
|
||||
if (insertError) {
|
||||
console.error("Failed to save subscription:", insertError);
|
||||
return NextResponse.json(
|
||||
{
|
||||
message:
|
||||
"An unexpected error occurred while saving subscription.",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Successfully subscribed!" },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("An unexpected server error occurred:", error);
|
||||
return NextResponse.json(
|
||||
{ message: "An unexpected error occurred." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { createClient } from '@/utils/supabase/client';
|
||||
|
||||
const supabase = createClient();
|
||||
import { PostMetadata } from '@/types';
|
||||
|
||||
interface AuthorProfile {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PostCard from '@/components/PostCard';
|
||||
import { getSortedPostsData, getPostsByAuthor } from '@/lib/markdown';
|
||||
import { getPostsByAuthor } from '@/lib/posts';
|
||||
import { PostMetadata } from '@/types';
|
||||
import { generateMetadata as createMetadata } from '@/components/Metadata';
|
||||
import type { Metadata } from 'next';
|
||||
@@ -10,19 +10,7 @@ interface PageProps {
|
||||
params: Promise<{ author: string }>;
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const posts = getSortedPostsData();
|
||||
const authors = Array.from(new Set(posts.flatMap(post => post.author)));
|
||||
|
||||
return authors.map((author) => ({
|
||||
author: author,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to generate author paths during build:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { author } = await params;
|
||||
@@ -35,7 +23,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
}
|
||||
|
||||
async function getPostsByAuthorData(author: string): Promise<PostMetadata[]> {
|
||||
return getPostsByAuthor(author);
|
||||
return await getPostsByAuthor(author);
|
||||
}
|
||||
|
||||
export default async function AuthorPage({ params }: PageProps) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { createClient } from '@/utils/supabase/server';
|
||||
import { generateMetadata as createMetadata } from '@/components/Metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
@@ -25,6 +27,7 @@ export async function generateMetadata(): Promise<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');
|
||||
|
||||
+4
-2
@@ -1,13 +1,15 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import React from 'react';
|
||||
import PostCard from '@/components/PostCard';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import { getSortedPostsData } from '@/lib/markdown';
|
||||
import { getSortedPostsData } from '@/lib/posts';
|
||||
import { PostMetadata } from '@/types';
|
||||
import Image from 'next/image';
|
||||
|
||||
// This function runs on the server during build time
|
||||
async function getHomeData() {
|
||||
const posts = getSortedPostsData();
|
||||
const posts = await getSortedPostsData();
|
||||
|
||||
// Create tag data
|
||||
const allTags = posts.flatMap(post => post.tags);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import ShareButtons from "@/components/ShareButtons";
|
||||
import { getPostData, getSortedPostsData } from "@/lib/markdown";
|
||||
import { getPostData, getSortedPostsData } from "@/lib/posts";
|
||||
import { PostData, PostMetadata } from "@/types";
|
||||
import { format } from "date-fns";
|
||||
import Image from "next/image";
|
||||
@@ -34,7 +36,7 @@ async function getPostAndMorePosts(
|
||||
id: string,
|
||||
): Promise<{ post: PostData; morePosts: PostMetadata[] }> {
|
||||
const post = await getPostData(id);
|
||||
const allPosts = getSortedPostsData();
|
||||
const allPosts = await getSortedPostsData();
|
||||
|
||||
// Filter out the current post and get a few related posts (by tags)
|
||||
const otherPosts = allPosts.filter((p) => p.id !== post.id);
|
||||
|
||||
+4
-2
@@ -1,7 +1,9 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { getSortedPostsData } from '@/lib/markdown';
|
||||
import { getSortedPostsData } from '@/lib/posts';
|
||||
import { PostMetadata } from '@/types';
|
||||
import { generateMetadata as createMetadata } from '@/components/Metadata';
|
||||
import type { Metadata } from 'next';
|
||||
@@ -16,7 +18,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
}
|
||||
|
||||
async function getPostsAndYears(): Promise<{ posts: PostMetadata[]; years: number[] }> {
|
||||
const posts = getSortedPostsData();
|
||||
const posts = await getSortedPostsData();
|
||||
|
||||
// Extract unique years from post dates
|
||||
const years = Array.from(new Set(
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import React, {Suspense} from 'react';
|
||||
import { getSortedPostsData } from '@/lib/markdown';
|
||||
import { getSortedPostsData } from '@/lib/posts';
|
||||
import { PostMetadata } from '@/types';
|
||||
import { generateMetadata as createMetadata } from '@/components/Metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
+3
-17
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PostCard from '@/components/PostCard';
|
||||
import { getSortedPostsData, getPostsByTag } from '@/lib/markdown';
|
||||
import { getPostsByTag } from '@/lib/posts';
|
||||
import { PostMetadata } from '@/types';
|
||||
import { generateMetadata as createMetadata } from '@/components/Metadata';
|
||||
import type { Metadata } from 'next';
|
||||
@@ -9,21 +9,7 @@ interface PageProps {
|
||||
params: Promise<{ tag: string }>;
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const posts = getSortedPostsData();
|
||||
const tags = Array.from(new Set(posts.flatMap(post => post.tags)));
|
||||
|
||||
return tags
|
||||
.filter(tag => tag && tag.trim() !== '') // Filter out empty or undefined tags
|
||||
.map((tag) => ({
|
||||
tag: encodeURIComponent(tag),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error generating static params for tags:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { tag } = await params;
|
||||
@@ -38,7 +24,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
|
||||
async function getPostsByTagData(tag: string): Promise<PostMetadata[]> {
|
||||
const decodedTag = decodeURIComponent(tag);
|
||||
return getPostsByTag(decodedTag);
|
||||
return await getPostsByTag(decodedTag);
|
||||
}
|
||||
|
||||
export default async function TagPage({ params }: PageProps) {
|
||||
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { getSortedPostsData } from '@/lib/markdown';
|
||||
import { getSortedPostsData } from '@/lib/posts';
|
||||
import { generateMetadata as createMetadata } from '@/components/Metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
@@ -16,7 +17,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
|
||||
async function getAllTags(): Promise<{ tag: string; count: number }[]> {
|
||||
try {
|
||||
const posts = getSortedPostsData();
|
||||
const posts = await getSortedPostsData();
|
||||
const tagCount: { [key: string]: number } = {};
|
||||
|
||||
// Count occurrences of each tag
|
||||
|
||||
Reference in New Issue
Block a user