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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { createClient } from '@/utils/supabase/client';
|
||||
|
||||
const supabase = createClient();
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface CommentFormProps {
|
||||
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { remark } from 'remark';
|
||||
import html from 'remark-html';
|
||||
import { PostData, PostMetadata } from '@/types';
|
||||
|
||||
// Make sure this is only used on the server side
|
||||
const postsDirectory = path.join(process.cwd(), 'data/posts');
|
||||
|
||||
export function getSortedPostsData(): PostMetadata[] {
|
||||
// Ensure we only run this on the server
|
||||
if (typeof window === 'undefined') {
|
||||
try {
|
||||
// Get file names under /posts
|
||||
const fileNames = fs.readdirSync(postsDirectory);
|
||||
const allPostsData = fileNames
|
||||
.filter(fileName => fileName.endsWith('.md')) // Only process .md files
|
||||
.map((fileName) => {
|
||||
// Remove ".md" from file name to get id
|
||||
const id = fileName.replace(/\.md$/, '');
|
||||
|
||||
// Skip if id is empty or undefined
|
||||
if (!id || id === 'undefined') {
|
||||
console.warn(`Skipping invalid filename: ${fileName}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read markdown file as string
|
||||
const fullPath = path.join(postsDirectory, fileName);
|
||||
const fileContents = fs.readFileSync(fullPath, 'utf8');
|
||||
|
||||
// Use gray-matter to parse the post metadata section
|
||||
const matterResult = matter(fileContents);
|
||||
|
||||
// Combine the data with the id
|
||||
return {
|
||||
id,
|
||||
title: matterResult.data.title || '',
|
||||
date: matterResult.data.date || '',
|
||||
excerpt: matterResult.data.excerpt || '',
|
||||
author: matterResult.data.author || '',
|
||||
tags: matterResult.data.tags || [],
|
||||
coverImage: matterResult.data.coverImage || undefined,
|
||||
} as PostMetadata;
|
||||
})
|
||||
.filter((post): post is PostMetadata => post !== null); // Filter out null values
|
||||
|
||||
// Sort posts by date
|
||||
return allPostsData.sort((a, b) => {
|
||||
if (a.date < b.date) {
|
||||
return 1;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reading posts directory:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Return empty array if running on client side
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getAllPostIds() {
|
||||
// Ensure we only run this on the server
|
||||
if (typeof window === 'undefined') {
|
||||
try {
|
||||
const fileNames = fs.readdirSync(postsDirectory);
|
||||
|
||||
return fileNames
|
||||
.filter(fileName => fileName.endsWith('.md'))
|
||||
.map((fileName) => {
|
||||
const id = fileName.replace(/\.md$/, '');
|
||||
if (!id || id === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
params: {
|
||||
id,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((item): item is { params: { id: string } } => item !== null);
|
||||
} catch (error) {
|
||||
console.error('Error reading posts directory:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Return empty array if running on client side
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function getPostData(id: string): Promise<PostData> {
|
||||
// Ensure we only run this on the server
|
||||
if (typeof window === 'undefined') {
|
||||
try {
|
||||
// Validate id
|
||||
if (!id || id === 'undefined') {
|
||||
throw new Error(`Invalid post id: ${id}`);
|
||||
}
|
||||
|
||||
const fullPath = path.join(postsDirectory, `${id}.md`);
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error(`Post file not found: ${fullPath}`);
|
||||
}
|
||||
|
||||
const fileContents = fs.readFileSync(fullPath, 'utf8');
|
||||
|
||||
// Use gray-matter to parse the post metadata section
|
||||
const matterResult = matter(fileContents);
|
||||
|
||||
// Use remark to convert markdown into HTML string
|
||||
const processedContent = await remark()
|
||||
.use(html, { sanitize: false })
|
||||
.process(matterResult.content);
|
||||
const contentHtml = processedContent.toString();
|
||||
|
||||
// Combine the data with the id and contentHtml
|
||||
return {
|
||||
id,
|
||||
content: contentHtml,
|
||||
title: matterResult.data.title || '',
|
||||
date: matterResult.data.date || '',
|
||||
excerpt: matterResult.data.excerpt || '',
|
||||
author: matterResult.data.author || '',
|
||||
tags: matterResult.data.tags || [],
|
||||
coverImage: matterResult.data.coverImage || undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error loading post ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Return empty object if running on client side (should never happen in practice)
|
||||
return {
|
||||
id: '',
|
||||
content: '',
|
||||
title: '',
|
||||
date: '',
|
||||
excerpt: '',
|
||||
author: '',
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function getPostsByTag(tag: string): PostMetadata[] {
|
||||
const allPosts = getSortedPostsData();
|
||||
return allPosts.filter(post => post.tags.includes(tag));
|
||||
}
|
||||
|
||||
export function getPostsByAuthor(author: string): PostMetadata[] {
|
||||
const allPosts = getSortedPostsData();
|
||||
return allPosts.filter(post => post.author.includes(author));
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { PostData, PostMetadata } from "@/types";
|
||||
|
||||
export async function getSortedPostsData(): Promise<PostMetadata[]> {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("posts")
|
||||
.select("id, title, date, excerpt, author, tags, cover_image")
|
||||
.eq("published", true)
|
||||
.order("date", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching posts:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return (data || []).map((post) => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
date: post.date,
|
||||
excerpt: post.excerpt,
|
||||
author: post.author,
|
||||
tags: post.tags || [],
|
||||
coverImage: post.cover_image || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAllPostIds(): Promise<{ params: { id: string } }[]> {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("posts")
|
||||
.select("id")
|
||||
.eq("published", true);
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching post IDs:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return (data || []).map((post) => ({
|
||||
params: { id: post.id },
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getPostData(id: string): Promise<PostData> {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("posts")
|
||||
.select("id, title, date, excerpt, content_html, author, tags, cover_image")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
console.error(`Error fetching post ${id}:`, error);
|
||||
throw new Error(`Post not found: ${id}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
date: data.date,
|
||||
excerpt: data.excerpt,
|
||||
content: data.content_html,
|
||||
author: data.author,
|
||||
tags: data.tags || [],
|
||||
coverImage: data.cover_image || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPostsByTag(tag: string): Promise<PostMetadata[]> {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("posts")
|
||||
.select("id, title, date, excerpt, author, tags, cover_image")
|
||||
.eq("published", true)
|
||||
.contains("tags", [tag])
|
||||
.order("date", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching posts by tag:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return (data || []).map((post) => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
date: post.date,
|
||||
excerpt: post.excerpt,
|
||||
author: post.author,
|
||||
tags: post.tags || [],
|
||||
coverImage: post.cover_image || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getPostsByAuthor(
|
||||
author: string
|
||||
): Promise<PostMetadata[]> {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("posts")
|
||||
.select("id, title, date, excerpt, author, tags, cover_image")
|
||||
.eq("published", true)
|
||||
.eq("author", author)
|
||||
.order("date", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching posts by author:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return (data || []).map((post) => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
date: post.date,
|
||||
excerpt: post.excerpt,
|
||||
author: post.author,
|
||||
tags: post.tags || [],
|
||||
coverImage: post.cover_image || undefined,
|
||||
}));
|
||||
}
|
||||
Generated
+119
-77
@@ -8,6 +8,7 @@
|
||||
"name": "confessions-of-grace",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.8.0",
|
||||
"@supabase/supabase-js": "^2.55.0",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
"@vercel/speed-insights": "^1.2.0",
|
||||
@@ -19,8 +20,7 @@
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-html": "^16.0.1",
|
||||
"supabase": "^2.34.3"
|
||||
"remark-html": "^16.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -28,6 +28,8 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv": "^17.3.1",
|
||||
"supabase": "^2.76.11",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"typescript": "^5",
|
||||
"wrangler": "^4.31.0"
|
||||
@@ -1103,6 +1105,7 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
@@ -1345,77 +1348,95 @@
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz",
|
||||
"integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==",
|
||||
"version": "2.97.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.97.0.tgz",
|
||||
"integrity": "sha512-2Og/1lqp+AIavr8qS2X04aSl8RBY06y4LrtIAGxat06XoXYiDxKNQMQzWDAKm1EyZFZVRNH48DO5YvIZ7la5fQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14"
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz",
|
||||
"integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==",
|
||||
"version": "2.97.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.97.0.tgz",
|
||||
"integrity": "sha512-fSaA0ZeBUS9hMgpGZt5shIZvfs3Mvx2ZdajQT4kv/whubqDBAp3GU5W8iIXy21MRvKmO2NpAj8/Q6y+ZkZyF/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/node-fetch": {
|
||||
"version": "2.6.15",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
|
||||
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "1.19.4",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz",
|
||||
"integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==",
|
||||
"version": "2.97.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.97.0.tgz",
|
||||
"integrity": "sha512-g4Ps0eaxZZurvfv/KGoo2XPZNpyNtjth9aW8eho9LZWM0bUuBtxPZw3ZQ6ERSpEGogshR+XNgwlSPIwcuHCNww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14"
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.1.tgz",
|
||||
"integrity": "sha512-edRFa2IrQw50kNntvUyS38hsL7t2d/psah6om6aNTLLcWem0R6bOUq7sk7DsGeSlNfuwEwWn57FdYSva6VddYw==",
|
||||
"version": "2.97.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.97.0.tgz",
|
||||
"integrity": "sha512-37Jw0NLaFP0CZd7qCan97D1zWutPrTSpgWxAw6Yok59JZoxp4IIKMrPeftJ3LZHmf+ILQOPy3i0pRDHM9FY36Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.13",
|
||||
"@types/phoenix": "^1.6.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tslib": "2.8.1",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/ssr": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.8.0.tgz",
|
||||
"integrity": "sha512-/PKk8kNFSs8QvvJ2vOww1mF5/c5W8y42duYtXvkOSe+yZKRgTTZywYG2l41pjhNomqESZCpZtXuWmYjFRMV+dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@supabase/supabase-js": "^2.76.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.11.0.tgz",
|
||||
"integrity": "sha512-Y+kx/wDgd4oasAgoAq0bsbQojwQ+ejIif8uczZ9qufRHWFLMU5cODT+ApHsSrDufqUcVKt+eyxtOXSkeh2v9ww==",
|
||||
"version": "2.97.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.97.0.tgz",
|
||||
"integrity": "sha512-9f6NniSBfuMxOWKwEFb+RjJzkfMdJUwv9oHuFJKfe/5VJR8cd90qw68m6Hn0ImGtwG37TUO+QHtoOechxRJ1Yg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14"
|
||||
"iceberg-js": "^0.8.1",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.55.0.tgz",
|
||||
"integrity": "sha512-Y1uV4nEMjQV1x83DGn7+Z9LOisVVRlY1geSARrUHbXWgbyKLZ6/08dvc0Us1r6AJ4tcKpwpCZWG9yDQYo1JgHg==",
|
||||
"version": "2.97.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.97.0.tgz",
|
||||
"integrity": "sha512-kTD91rZNO4LvRUHv4x3/4hNmsEd2ofkYhuba2VMUPRVef1RCmnHtm7rIws38Fg0yQnOSZOplQzafn0GSiy6GVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.71.1",
|
||||
"@supabase/functions-js": "2.4.5",
|
||||
"@supabase/node-fetch": "2.6.15",
|
||||
"@supabase/postgrest-js": "1.19.4",
|
||||
"@supabase/realtime-js": "2.15.1",
|
||||
"@supabase/storage-js": "^2.10.4"
|
||||
"@supabase/auth-js": "2.97.0",
|
||||
"@supabase/functions-js": "2.97.0",
|
||||
"@supabase/postgrest-js": "2.97.0",
|
||||
"@supabase/realtime-js": "2.97.0",
|
||||
"@supabase/storage-js": "2.97.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
@@ -1762,9 +1783,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/phoenix": {
|
||||
"version": "1.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
||||
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
@@ -1885,6 +1906,7 @@
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
@@ -1959,6 +1981,7 @@
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz",
|
||||
"integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cmd-shim": "^8.0.0",
|
||||
@@ -2074,6 +2097,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -2089,6 +2113,7 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz",
|
||||
"integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
@@ -2108,7 +2133,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -2142,6 +2166,7 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -2219,6 +2244,19 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.203",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.203.tgz",
|
||||
@@ -2335,6 +2373,7 @@
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2358,6 +2397,7 @@
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
@@ -2481,6 +2521,7 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
@@ -2490,10 +2531,20 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iceberg-js": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.19"
|
||||
@@ -3424,6 +3475,7 @@
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -3433,6 +3485,7 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
@@ -3551,6 +3604,7 @@
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3570,6 +3624,7 @@
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
@@ -3603,6 +3658,7 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz",
|
||||
"integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
@@ -3680,6 +3736,7 @@
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
|
||||
"integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
@@ -3720,6 +3777,7 @@
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz",
|
||||
"integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
@@ -3870,6 +3928,7 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -3950,16 +4009,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/supabase": {
|
||||
"version": "2.75.0",
|
||||
"resolved": "https://registry.npmjs.org/supabase/-/supabase-2.75.0.tgz",
|
||||
"integrity": "sha512-Xaai8Wp7F03OMkOWSyS1OMMdk5DxkauJxcMMesj4mO6jDGEZpWBwgo/gIUb4hsckexmr/RXYdpDpTbUIMXahbw==",
|
||||
"version": "2.76.11",
|
||||
"resolved": "https://registry.npmjs.org/supabase/-/supabase-2.76.11.tgz",
|
||||
"integrity": "sha512-dr+YndEjJe54CBHayvN9B+Hhx9SiuB4h4N/SHBWsplgEkO6ubQz/Gq0/O+iBCdjSkeEjk/JLi4tc+A5yLgCjXw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bin-links": "^6.0.0",
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"tar": "7.5.7"
|
||||
"tar": "7.5.9"
|
||||
},
|
||||
"bin": {
|
||||
"supabase": "bin/supabase"
|
||||
@@ -3999,9 +4059,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.7",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
|
||||
"integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
|
||||
"version": "7.5.9",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
|
||||
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
@@ -4014,12 +4075,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
@@ -4242,27 +4297,12 @@
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/workerd": {
|
||||
"version": "1.20260128.0",
|
||||
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260128.0.tgz",
|
||||
@@ -4323,6 +4363,7 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz",
|
||||
"integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"imurmurhash": "^0.1.4",
|
||||
@@ -4333,9 +4374,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -4357,6 +4398,7 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
+6
-3
@@ -6,9 +6,11 @@
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"migrate-posts": "npx tsx scripts/migrate-posts.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.8.0",
|
||||
"@supabase/supabase-js": "^2.55.0",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
"@vercel/speed-insights": "^1.2.0",
|
||||
@@ -20,8 +22,7 @@
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-html": "^16.0.1",
|
||||
"supabase": "^2.34.3"
|
||||
"remark-html": "^16.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -29,6 +30,8 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv": "^17.3.1",
|
||||
"supabase": "^2.76.11",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"typescript": "^5",
|
||||
"wrangler": "^4.31.0"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { updateSession } from "@/utils/supabase/middleware";
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
return await updateSession(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - public folder assets
|
||||
*/
|
||||
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config({ path: ".env.local" });
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import matter from "gray-matter";
|
||||
import { remark } from "remark";
|
||||
import html from "remark-html";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey =
|
||||
process.env.SUPABASE_SECRET_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
console.error(
|
||||
"Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SECRET_KEY in .env.local"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
const postsDirectory = path.join(process.cwd(), "data/posts");
|
||||
|
||||
async function migratePost(fileName: string) {
|
||||
const id = fileName.replace(/\.md$/, "");
|
||||
const fullPath = path.join(postsDirectory, fileName);
|
||||
const fileContents = fs.readFileSync(fullPath, "utf8");
|
||||
|
||||
const matterResult = matter(fileContents);
|
||||
|
||||
// Render markdown to HTML
|
||||
const processedContent = await remark()
|
||||
.use(html, { sanitize: false })
|
||||
.process(matterResult.content);
|
||||
const contentHtml = processedContent.toString();
|
||||
|
||||
const post = {
|
||||
id,
|
||||
title: matterResult.data.title || "",
|
||||
date: new Date(matterResult.data.date).toISOString(),
|
||||
excerpt: matterResult.data.excerpt || "",
|
||||
content: matterResult.content, // raw markdown
|
||||
content_html: contentHtml,
|
||||
author: matterResult.data.author || "",
|
||||
tags: matterResult.data.tags || [],
|
||||
cover_image: matterResult.data.coverImage || null,
|
||||
published: true,
|
||||
};
|
||||
|
||||
const { error } = await supabase.from("posts").upsert(post, {
|
||||
onConflict: "id",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(`Failed to upsert post "${id}":`, error.message);
|
||||
} else {
|
||||
console.log(`Migrated: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Starting post migration...\n");
|
||||
|
||||
const fileNames = fs
|
||||
.readdirSync(postsDirectory)
|
||||
.filter((f) => f.endsWith(".md"));
|
||||
|
||||
console.log(`Found ${fileNames.length} markdown files.\n`);
|
||||
|
||||
for (const fileName of fileNames) {
|
||||
await migratePost(fileName);
|
||||
}
|
||||
|
||||
console.log("\nMigration complete!");
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,8 @@
|
||||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
|
||||
# dotenvx
|
||||
.env.keys
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -0,0 +1,388 @@
|
||||
# For detailed configuration reference documentation, visit:
|
||||
# https://supabase.com/docs/guides/local-development/cli/config
|
||||
# A string used to distinguish different Supabase projects on the same host. Defaults to the
|
||||
# working directory name when running `supabase init`.
|
||||
project_id = "confessions-of-grace"
|
||||
|
||||
[api]
|
||||
enabled = true
|
||||
# Port to use for the API URL.
|
||||
port = 54321
|
||||
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
|
||||
# endpoints. `public` and `graphql_public` schemas are included by default.
|
||||
schemas = ["public", "graphql_public"]
|
||||
# Extra schemas to add to the search_path of every request.
|
||||
extra_search_path = ["public", "extensions"]
|
||||
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
|
||||
# for accidental or malicious requests.
|
||||
max_rows = 1000
|
||||
|
||||
[api.tls]
|
||||
# Enable HTTPS endpoints locally using a self-signed certificate.
|
||||
enabled = false
|
||||
# Paths to self-signed certificate pair.
|
||||
# cert_path = "../certs/my-cert.pem"
|
||||
# key_path = "../certs/my-key.pem"
|
||||
|
||||
[db]
|
||||
# Port to use for the local database URL.
|
||||
port = 54322
|
||||
# Port used by db diff command to initialize the shadow database.
|
||||
shadow_port = 54320
|
||||
# Maximum amount of time to wait for health check when starting the local database.
|
||||
health_timeout = "2m"
|
||||
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
|
||||
# server_version;` on the remote database to check.
|
||||
major_version = 17
|
||||
|
||||
[db.pooler]
|
||||
enabled = false
|
||||
# Port to use for the local connection pooler.
|
||||
port = 54329
|
||||
# Specifies when a server connection can be reused by other clients.
|
||||
# Configure one of the supported pooler modes: `transaction`, `session`.
|
||||
pool_mode = "transaction"
|
||||
# How many server connections to allow per user/database pair.
|
||||
default_pool_size = 20
|
||||
# Maximum number of client connections allowed.
|
||||
max_client_conn = 100
|
||||
|
||||
# [db.vault]
|
||||
# secret_key = "env(SECRET_VALUE)"
|
||||
|
||||
[db.migrations]
|
||||
# If disabled, migrations will be skipped during a db push or reset.
|
||||
enabled = true
|
||||
# Specifies an ordered list of schema files that describe your database.
|
||||
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
|
||||
schema_paths = []
|
||||
|
||||
[db.seed]
|
||||
# If enabled, seeds the database after migrations during a db reset.
|
||||
enabled = true
|
||||
# Specifies an ordered list of seed files to load during db reset.
|
||||
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
|
||||
sql_paths = ["./seed.sql"]
|
||||
|
||||
[db.network_restrictions]
|
||||
# Enable management of network restrictions.
|
||||
enabled = false
|
||||
# List of IPv4 CIDR blocks allowed to connect to the database.
|
||||
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
|
||||
allowed_cidrs = ["0.0.0.0/0"]
|
||||
# List of IPv6 CIDR blocks allowed to connect to the database.
|
||||
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
|
||||
allowed_cidrs_v6 = ["::/0"]
|
||||
|
||||
# Uncomment to reject non-secure connections to the database.
|
||||
# [db.ssl_enforcement]
|
||||
# enabled = true
|
||||
|
||||
[realtime]
|
||||
enabled = true
|
||||
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
|
||||
# ip_version = "IPv6"
|
||||
# The maximum length in bytes of HTTP request headers. (default: 4096)
|
||||
# max_header_length = 4096
|
||||
|
||||
[studio]
|
||||
enabled = true
|
||||
# Port to use for Supabase Studio.
|
||||
port = 54323
|
||||
# External URL of the API server that frontend connects to.
|
||||
api_url = "http://127.0.0.1"
|
||||
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
|
||||
openai_api_key = "env(OPENAI_API_KEY)"
|
||||
|
||||
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
|
||||
# are monitored, and you can view the emails that would have been sent from the web interface.
|
||||
[inbucket]
|
||||
enabled = true
|
||||
# Port to use for the email testing server web interface.
|
||||
port = 54324
|
||||
# Uncomment to expose additional ports for testing user applications that send emails.
|
||||
# smtp_port = 54325
|
||||
# pop3_port = 54326
|
||||
# admin_email = "[email protected]"
|
||||
# sender_name = "Admin"
|
||||
|
||||
[storage]
|
||||
enabled = true
|
||||
# The maximum file size allowed (e.g. "5MB", "500KB").
|
||||
file_size_limit = "50MiB"
|
||||
|
||||
# Uncomment to configure local storage buckets
|
||||
# [storage.buckets.images]
|
||||
# public = false
|
||||
# file_size_limit = "50MiB"
|
||||
# allowed_mime_types = ["image/png", "image/jpeg"]
|
||||
# objects_path = "./images"
|
||||
|
||||
# Allow connections via S3 compatible clients
|
||||
[storage.s3_protocol]
|
||||
enabled = true
|
||||
|
||||
# Image transformation API is available to Supabase Pro plan.
|
||||
# [storage.image_transformation]
|
||||
# enabled = true
|
||||
|
||||
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
|
||||
# This feature is only available on the hosted platform.
|
||||
[storage.analytics]
|
||||
enabled = false
|
||||
max_namespaces = 5
|
||||
max_tables = 10
|
||||
max_catalogs = 2
|
||||
|
||||
# Analytics Buckets is available to Supabase Pro plan.
|
||||
# [storage.analytics.buckets.my-warehouse]
|
||||
|
||||
# Store vector embeddings in S3 for large and durable datasets
|
||||
# This feature is only available on the hosted platform.
|
||||
[storage.vector]
|
||||
enabled = false
|
||||
max_buckets = 10
|
||||
max_indexes = 5
|
||||
|
||||
# Vector Buckets is available to Supabase Pro plan.
|
||||
# [storage.vector.buckets.documents-openai]
|
||||
|
||||
[auth]
|
||||
enabled = true
|
||||
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
||||
# in emails.
|
||||
site_url = "http://127.0.0.1:3000"
|
||||
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
||||
additional_redirect_urls = ["https://127.0.0.1:3000"]
|
||||
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
||||
jwt_expiry = 3600
|
||||
# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:<port>/auth/v1).
|
||||
# jwt_issuer = ""
|
||||
# Path to JWT signing key. DO NOT commit your signing keys file to git.
|
||||
# signing_keys_path = "./signing_keys.json"
|
||||
# If disabled, the refresh token will never expire.
|
||||
enable_refresh_token_rotation = true
|
||||
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
|
||||
# Requires enable_refresh_token_rotation = true.
|
||||
refresh_token_reuse_interval = 10
|
||||
# Allow/disallow new user signups to your project.
|
||||
enable_signup = true
|
||||
# Allow/disallow anonymous sign-ins to your project.
|
||||
enable_anonymous_sign_ins = false
|
||||
# Allow/disallow testing manual linking of accounts
|
||||
enable_manual_linking = false
|
||||
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
|
||||
minimum_password_length = 6
|
||||
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
|
||||
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
|
||||
password_requirements = ""
|
||||
|
||||
[auth.rate_limit]
|
||||
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
|
||||
email_sent = 2
|
||||
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
|
||||
sms_sent = 30
|
||||
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
|
||||
anonymous_users = 30
|
||||
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
|
||||
token_refresh = 150
|
||||
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
|
||||
sign_in_sign_ups = 30
|
||||
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
|
||||
token_verifications = 30
|
||||
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
|
||||
web3 = 30
|
||||
|
||||
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
|
||||
# [auth.captcha]
|
||||
# enabled = true
|
||||
# provider = "hcaptcha"
|
||||
# secret = ""
|
||||
|
||||
[auth.email]
|
||||
# Allow/disallow new user signups via email to your project.
|
||||
enable_signup = true
|
||||
# If enabled, a user will be required to confirm any email change on both the old, and new email
|
||||
# addresses. If disabled, only the new email is required to confirm.
|
||||
double_confirm_changes = true
|
||||
# If enabled, users need to confirm their email address before signing in.
|
||||
enable_confirmations = false
|
||||
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
|
||||
secure_password_change = false
|
||||
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
|
||||
max_frequency = "1s"
|
||||
# Number of characters used in the email OTP.
|
||||
otp_length = 6
|
||||
# Number of seconds before the email OTP expires (defaults to 1 hour).
|
||||
otp_expiry = 3600
|
||||
|
||||
# Use a production-ready SMTP server
|
||||
# [auth.email.smtp]
|
||||
# enabled = true
|
||||
# host = "smtp.sendgrid.net"
|
||||
# port = 587
|
||||
# user = "apikey"
|
||||
# pass = "env(SENDGRID_API_KEY)"
|
||||
# admin_email = "[email protected]"
|
||||
# sender_name = "Admin"
|
||||
|
||||
# Uncomment to customize email template
|
||||
# [auth.email.template.invite]
|
||||
# subject = "You have been invited"
|
||||
# content_path = "./supabase/templates/invite.html"
|
||||
|
||||
# Uncomment to customize notification email template
|
||||
# [auth.email.notification.password_changed]
|
||||
# enabled = true
|
||||
# subject = "Your password has been changed"
|
||||
# content_path = "./templates/password_changed_notification.html"
|
||||
|
||||
[auth.sms]
|
||||
# Allow/disallow new user signups via SMS to your project.
|
||||
enable_signup = false
|
||||
# If enabled, users need to confirm their phone number before signing in.
|
||||
enable_confirmations = false
|
||||
# Template for sending OTP to users
|
||||
template = "Your code is {{ .Code }}"
|
||||
# Controls the minimum amount of time that must pass before sending another sms otp.
|
||||
max_frequency = "5s"
|
||||
|
||||
# Use pre-defined map of phone number to OTP for testing.
|
||||
# [auth.sms.test_otp]
|
||||
# 4152127777 = "123456"
|
||||
|
||||
# Configure logged in session timeouts.
|
||||
# [auth.sessions]
|
||||
# Force log out after the specified duration.
|
||||
# timebox = "24h"
|
||||
# Force log out if the user has been inactive longer than the specified duration.
|
||||
# inactivity_timeout = "8h"
|
||||
|
||||
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
|
||||
# [auth.hook.before_user_created]
|
||||
# enabled = true
|
||||
# uri = "pg-functions://postgres/auth/before-user-created-hook"
|
||||
|
||||
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
|
||||
# [auth.hook.custom_access_token]
|
||||
# enabled = true
|
||||
# uri = "pg-functions://<database>/<schema>/<hook_name>"
|
||||
|
||||
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
|
||||
[auth.sms.twilio]
|
||||
enabled = false
|
||||
account_sid = ""
|
||||
message_service_sid = ""
|
||||
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
|
||||
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
|
||||
|
||||
# Multi-factor-authentication is available to Supabase Pro plan.
|
||||
[auth.mfa]
|
||||
# Control how many MFA factors can be enrolled at once per user.
|
||||
max_enrolled_factors = 10
|
||||
|
||||
# Control MFA via App Authenticator (TOTP)
|
||||
[auth.mfa.totp]
|
||||
enroll_enabled = false
|
||||
verify_enabled = false
|
||||
|
||||
# Configure MFA via Phone Messaging
|
||||
[auth.mfa.phone]
|
||||
enroll_enabled = false
|
||||
verify_enabled = false
|
||||
otp_length = 6
|
||||
template = "Your code is {{ .Code }}"
|
||||
max_frequency = "5s"
|
||||
|
||||
# Configure MFA via WebAuthn
|
||||
# [auth.mfa.web_authn]
|
||||
# enroll_enabled = true
|
||||
# verify_enabled = true
|
||||
|
||||
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
|
||||
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
|
||||
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
|
||||
[auth.external.apple]
|
||||
enabled = false
|
||||
client_id = ""
|
||||
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
|
||||
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
|
||||
# Overrides the default auth redirectUrl.
|
||||
redirect_uri = ""
|
||||
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
|
||||
# or any other third-party OIDC providers.
|
||||
url = ""
|
||||
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
|
||||
skip_nonce_check = false
|
||||
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
|
||||
email_optional = false
|
||||
|
||||
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
|
||||
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
|
||||
[auth.web3.solana]
|
||||
enabled = false
|
||||
|
||||
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.firebase]
|
||||
enabled = false
|
||||
# project_id = "my-firebase-project"
|
||||
|
||||
# Use Auth0 as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.auth0]
|
||||
enabled = false
|
||||
# tenant = "my-auth0-tenant"
|
||||
# tenant_region = "us"
|
||||
|
||||
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.aws_cognito]
|
||||
enabled = false
|
||||
# user_pool_id = "my-user-pool-id"
|
||||
# user_pool_region = "us-east-1"
|
||||
|
||||
# Use Clerk as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.clerk]
|
||||
enabled = false
|
||||
# Obtain from https://clerk.com/setup/supabase
|
||||
# domain = "example.clerk.accounts.dev"
|
||||
|
||||
# OAuth server configuration
|
||||
[auth.oauth_server]
|
||||
# Enable OAuth server functionality
|
||||
enabled = false
|
||||
# Path for OAuth consent flow UI
|
||||
authorization_url_path = "/oauth/consent"
|
||||
# Allow dynamic client registration
|
||||
allow_dynamic_registration = false
|
||||
|
||||
[edge_runtime]
|
||||
enabled = true
|
||||
# Supported request policies: `oneshot`, `per_worker`.
|
||||
# `per_worker` (default) — enables hot reload during local development.
|
||||
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
|
||||
policy = "per_worker"
|
||||
# Port to attach the Chrome inspector for debugging edge functions.
|
||||
inspector_port = 8083
|
||||
# The Deno major version to use.
|
||||
deno_version = 2
|
||||
|
||||
# [edge_runtime.secrets]
|
||||
# secret_key = "env(SECRET_VALUE)"
|
||||
|
||||
[analytics]
|
||||
enabled = true
|
||||
port = 54327
|
||||
# Configure one of the supported backends: `postgres`, `bigquery`.
|
||||
backend = "postgres"
|
||||
|
||||
# Experimental features may be deprecated any time
|
||||
[experimental]
|
||||
# Configures Postgres storage engine to use OrioleDB (S3)
|
||||
orioledb_version = ""
|
||||
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
|
||||
s3_host = "env(S3_HOST)"
|
||||
# Configures S3 bucket region, eg. us-east-1
|
||||
s3_region = "env(S3_REGION)"
|
||||
# Configures AWS_ACCESS_KEY_ID for S3 bucket
|
||||
s3_access_key = "env(S3_ACCESS_KEY)"
|
||||
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
|
||||
s3_secret_key = "env(S3_SECRET_KEY)"
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Create posts table
|
||||
create table if not exists public.posts (
|
||||
id text primary key, -- slug-based ID
|
||||
title text not null,
|
||||
date timestamptz not null,
|
||||
excerpt text not null default '',
|
||||
content text not null default '', -- raw markdown
|
||||
content_html text not null default '', -- pre-rendered HTML
|
||||
author text not null default '',
|
||||
tags text[] not null default '{}',
|
||||
cover_image text,
|
||||
published boolean not null default false,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
create index if not exists idx_posts_date on public.posts (date desc);
|
||||
create index if not exists idx_posts_published on public.posts (published);
|
||||
create index if not exists idx_posts_author on public.posts (author);
|
||||
create index if not exists idx_posts_tags on public.posts using gin (tags);
|
||||
|
||||
-- Auto-update trigger for updated_at
|
||||
create or replace function public.handle_updated_at()
|
||||
returns trigger as $$
|
||||
begin
|
||||
new.updated_at = now();
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
create trigger on_posts_updated
|
||||
before update on public.posts
|
||||
for each row
|
||||
execute function public.handle_updated_at();
|
||||
|
||||
-- Enable RLS
|
||||
alter table public.posts enable row level security;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Create admin_users table
|
||||
create table if not exists public.admin_users (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
email text not null,
|
||||
role text not null default 'editor' check (role in ('super_admin', 'admin', 'editor')),
|
||||
created_at timestamptz not null default now(),
|
||||
unique(user_id)
|
||||
);
|
||||
|
||||
-- Index on user_id for fast lookups
|
||||
create index if not exists idx_admin_users_user_id on public.admin_users (user_id);
|
||||
|
||||
-- Enable RLS
|
||||
alter table public.admin_users enable row level security;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Create tables that were originally created via Supabase dashboard.
|
||||
-- Using IF NOT EXISTS so this is safe to run on existing databases.
|
||||
|
||||
-- Comments table
|
||||
create table if not exists public.comments (
|
||||
id bigint generated always as identity primary key,
|
||||
name text not null,
|
||||
email text not null,
|
||||
comment text not null,
|
||||
post_id text not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.comments enable row level security;
|
||||
|
||||
-- Subscriptions table
|
||||
create table if not exists public.subscriptions (
|
||||
id bigint generated always as identity primary key,
|
||||
email text not null unique,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.subscriptions enable row level security;
|
||||
|
||||
-- Authors table
|
||||
create table if not exists public.authors (
|
||||
name text primary key,
|
||||
bio text not null default '',
|
||||
x_link text,
|
||||
fb_link text,
|
||||
insta_link text,
|
||||
pfp_link text
|
||||
);
|
||||
|
||||
alter table public.authors enable row level security;
|
||||
@@ -0,0 +1,151 @@
|
||||
-- Helper function: check if current user is an admin
|
||||
create or replace function public.is_admin()
|
||||
returns boolean as $$
|
||||
begin
|
||||
return exists (
|
||||
select 1 from public.admin_users
|
||||
where user_id = auth.uid()
|
||||
);
|
||||
end;
|
||||
$$ language plpgsql security definer;
|
||||
|
||||
-- Helper function: check if current user has a specific role or higher
|
||||
create or replace function public.has_admin_role(required_role text)
|
||||
returns boolean as $$
|
||||
declare
|
||||
user_role text;
|
||||
begin
|
||||
select role into user_role from public.admin_users
|
||||
where user_id = auth.uid();
|
||||
|
||||
if user_role is null then
|
||||
return false;
|
||||
end if;
|
||||
|
||||
-- Role hierarchy: super_admin > admin > editor
|
||||
if required_role = 'editor' then
|
||||
return user_role in ('editor', 'admin', 'super_admin');
|
||||
elsif required_role = 'admin' then
|
||||
return user_role in ('admin', 'super_admin');
|
||||
elsif required_role = 'super_admin' then
|
||||
return user_role = 'super_admin';
|
||||
end if;
|
||||
|
||||
return false;
|
||||
end;
|
||||
$$ language plpgsql security definer;
|
||||
|
||||
-- ============================================
|
||||
-- POSTS policies (drop first in case of partial previous run)
|
||||
-- ============================================
|
||||
drop policy if exists "Public can read published posts" on public.posts;
|
||||
drop policy if exists "Editors can insert posts" on public.posts;
|
||||
drop policy if exists "Editors can update posts" on public.posts;
|
||||
drop policy if exists "Admins can delete posts" on public.posts;
|
||||
|
||||
create policy "Public can read published posts"
|
||||
on public.posts for select
|
||||
using (published = true or public.is_admin());
|
||||
|
||||
create policy "Editors can insert posts"
|
||||
on public.posts for insert
|
||||
with check (public.has_admin_role('editor'));
|
||||
|
||||
create policy "Editors can update posts"
|
||||
on public.posts for update
|
||||
using (public.has_admin_role('editor'));
|
||||
|
||||
create policy "Admins can delete posts"
|
||||
on public.posts for delete
|
||||
using (public.has_admin_role('admin'));
|
||||
|
||||
-- ============================================
|
||||
-- COMMENTS policies
|
||||
-- ============================================
|
||||
drop policy if exists "Public can read comments" on public.comments;
|
||||
drop policy if exists "Public can insert comments" on public.comments;
|
||||
drop policy if exists "Editors can delete comments" on public.comments;
|
||||
drop policy if exists "Editors can update comments" on public.comments;
|
||||
|
||||
create policy "Public can read comments"
|
||||
on public.comments for select
|
||||
using (true);
|
||||
|
||||
create policy "Public can insert comments"
|
||||
on public.comments for insert
|
||||
with check (true);
|
||||
|
||||
create policy "Editors can delete comments"
|
||||
on public.comments for delete
|
||||
using (public.has_admin_role('editor'));
|
||||
|
||||
create policy "Editors can update comments"
|
||||
on public.comments for update
|
||||
using (public.has_admin_role('editor'));
|
||||
|
||||
-- ============================================
|
||||
-- SUBSCRIPTIONS policies
|
||||
-- ============================================
|
||||
drop policy if exists "Public can insert subscriptions" on public.subscriptions;
|
||||
drop policy if exists "Public can read subscriptions" on public.subscriptions;
|
||||
drop policy if exists "Admins can delete subscriptions" on public.subscriptions;
|
||||
|
||||
create policy "Public can insert subscriptions"
|
||||
on public.subscriptions for insert
|
||||
with check (true);
|
||||
|
||||
create policy "Public can read subscriptions"
|
||||
on public.subscriptions for select
|
||||
using (true);
|
||||
|
||||
create policy "Admins can delete subscriptions"
|
||||
on public.subscriptions for delete
|
||||
using (public.has_admin_role('admin'));
|
||||
|
||||
-- ============================================
|
||||
-- AUTHORS policies
|
||||
-- ============================================
|
||||
drop policy if exists "Public can read authors" on public.authors;
|
||||
drop policy if exists "Admins can insert authors" on public.authors;
|
||||
drop policy if exists "Admins can update authors" on public.authors;
|
||||
drop policy if exists "Super admins can delete authors" on public.authors;
|
||||
|
||||
create policy "Public can read authors"
|
||||
on public.authors for select
|
||||
using (true);
|
||||
|
||||
create policy "Admins can insert authors"
|
||||
on public.authors for insert
|
||||
with check (public.has_admin_role('admin'));
|
||||
|
||||
create policy "Admins can update authors"
|
||||
on public.authors for update
|
||||
using (public.has_admin_role('admin'));
|
||||
|
||||
create policy "Super admins can delete authors"
|
||||
on public.authors for delete
|
||||
using (public.has_admin_role('super_admin'));
|
||||
|
||||
-- ============================================
|
||||
-- ADMIN_USERS policies
|
||||
-- ============================================
|
||||
drop policy if exists "Admins can read admin users" on public.admin_users;
|
||||
drop policy if exists "Super admins can insert admin users" on public.admin_users;
|
||||
drop policy if exists "Super admins can update admin users" on public.admin_users;
|
||||
drop policy if exists "Super admins can delete admin users" on public.admin_users;
|
||||
|
||||
create policy "Admins can read admin users"
|
||||
on public.admin_users for select
|
||||
using (public.is_admin());
|
||||
|
||||
create policy "Super admins can insert admin users"
|
||||
on public.admin_users for insert
|
||||
with check (public.has_admin_role('super_admin'));
|
||||
|
||||
create policy "Super admins can update admin users"
|
||||
on public.admin_users for update
|
||||
using (public.has_admin_role('super_admin'));
|
||||
|
||||
create policy "Super admins can delete admin users"
|
||||
on public.admin_users for delete
|
||||
using (public.has_admin_role('super_admin'));
|
||||
+19
-5
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -11,7 +15,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
@@ -19,9 +23,19 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
+63
-19
@@ -1,20 +1,64 @@
|
||||
export interface PostData {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
author: string;
|
||||
tags: string[];
|
||||
coverImage?: string;
|
||||
}
|
||||
|
||||
export interface PostMetadata {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
excerpt: string;
|
||||
author: string;
|
||||
tags: string[];
|
||||
coverImage?: string;
|
||||
}
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
author: string;
|
||||
tags: string[];
|
||||
coverImage?: string;
|
||||
}
|
||||
|
||||
export interface PostMetadata {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
excerpt: string;
|
||||
author: string;
|
||||
tags: string[];
|
||||
coverImage?: string;
|
||||
}
|
||||
|
||||
export interface PostFormData {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
author: string;
|
||||
tags: string[];
|
||||
coverImage?: string;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
role: "super_admin" | "admin" | "editor";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
comment: string;
|
||||
post_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: number;
|
||||
email: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Author {
|
||||
name: string;
|
||||
bio: string;
|
||||
x_link?: string;
|
||||
fb_link?: string;
|
||||
insta_link?: string;
|
||||
pfp_link?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
|
||||
export function createClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
export async function updateSession(request: NextRequest) {
|
||||
let supabaseResponse = NextResponse.next({
|
||||
request,
|
||||
});
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
request.cookies.set(name, value)
|
||||
);
|
||||
supabaseResponse = NextResponse.next({
|
||||
request,
|
||||
});
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
supabaseResponse.cookies.set(name, value, options)
|
||||
);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Refresh the session
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
// Protect admin routes (except login)
|
||||
const isAdminRoute = request.nextUrl.pathname.startsWith("/admin");
|
||||
const isLoginPage = request.nextUrl.pathname === "/admin/login";
|
||||
|
||||
if (isAdminRoute && !isLoginPage) {
|
||||
if (!user) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/admin/login";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// Verify the user is in admin_users table
|
||||
const { data: adminUser } = await supabase
|
||||
.from("admin_users")
|
||||
.select("role")
|
||||
.eq("user_id", user.id)
|
||||
.single();
|
||||
|
||||
if (!adminUser) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/admin/login";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
}
|
||||
|
||||
// If logged-in admin visits login page, redirect to dashboard
|
||||
if (isLoginPage && user) {
|
||||
const { data: adminUser } = await supabase
|
||||
.from("admin_users")
|
||||
.select("role")
|
||||
.eq("user_id", user.id)
|
||||
.single();
|
||||
|
||||
if (adminUser) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/admin";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
}
|
||||
|
||||
return supabaseResponse;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function createClient() {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch {
|
||||
// The `setAll` method was called from a Server Component.
|
||||
// This can be ignored if you have middleware refreshing sessions.
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user