From 10cd973e355c8719b80b61e2b0227166e3b9543d Mon Sep 17 00:00:00 2001 From: austin Date: Wed, 22 Jul 2026 13:11:56 -0500 Subject: [PATCH] Refactor to static markdown (remove Supabase backend) + self-host build Posts now read from data/posts/*.md via gray-matter + remark; authors derived from posts. Removes admin dashboard, comments, subscriptions, and all Supabase usage. Adds Dockerfile (Next.js standalone) + Gitea Actions CI. output: standalone. --- .dockerignore | 9 + .gitea/workflows/deploy.yml | 16 ++ Dockerfile | 24 ++ .../(dashboard)/authors/[name]/edit/page.tsx | 116 -------- app/admin/(dashboard)/authors/actions.ts | 71 ----- app/admin/(dashboard)/authors/new/page.tsx | 89 ------ app/admin/(dashboard)/authors/page.tsx | 100 ------- app/admin/(dashboard)/comments/actions.ts | 16 -- app/admin/(dashboard)/comments/page.tsx | 78 ----- .../(dashboard)/components/AdminSidebar.tsx | 87 ------ .../components/DeleteConfirmDialog.tsx | 57 ---- .../(dashboard)/components/PostEditor.tsx | 202 ------------- app/admin/(dashboard)/layout.tsx | 36 --- app/admin/(dashboard)/page.tsx | 131 --------- .../(dashboard)/posts/[id]/edit/page.tsx | 52 ---- app/admin/(dashboard)/posts/actions.ts | 119 -------- app/admin/(dashboard)/posts/new/page.tsx | 21 -- app/admin/(dashboard)/posts/page.tsx | 99 ------- .../subscriptions/ExportButton.tsx | 34 --- .../(dashboard)/subscriptions/actions.ts | 19 -- app/admin/(dashboard)/subscriptions/page.tsx | 67 ----- app/admin/(dashboard)/users/actions.ts | 79 ------ app/admin/(dashboard)/users/page.tsx | 198 ------------- app/admin/layout.tsx | 9 - app/admin/login/page.tsx | 119 -------- app/api/comments/route.ts | 81 ------ app/api/subscribe/route.ts | 70 ----- app/authors/[author]/AuthorProfile.tsx | 98 +------ app/authors/[author]/page.tsx | 22 +- app/authors/page.tsx | 53 +--- app/posts/[id]/page.tsx | 3 - components/CommentSection.tsx | 268 ------------------ components/SubscribeForm.tsx | 42 +-- lib/authors.ts | 57 ++++ lib/posts.ts | 163 +++++------ next.config.ts | 2 +- proxy.ts | 19 -- scripts/migrate-posts.ts | 78 ----- utils/supabase/client.ts | 8 - utils/supabase/middleware.ts | 78 ----- utils/supabase/server.ts | 28 -- 41 files changed, 214 insertions(+), 2704 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/deploy.yml create mode 100644 Dockerfile delete mode 100644 app/admin/(dashboard)/authors/[name]/edit/page.tsx delete mode 100644 app/admin/(dashboard)/authors/actions.ts delete mode 100644 app/admin/(dashboard)/authors/new/page.tsx delete mode 100644 app/admin/(dashboard)/authors/page.tsx delete mode 100644 app/admin/(dashboard)/comments/actions.ts delete mode 100644 app/admin/(dashboard)/comments/page.tsx delete mode 100644 app/admin/(dashboard)/components/AdminSidebar.tsx delete mode 100644 app/admin/(dashboard)/components/DeleteConfirmDialog.tsx delete mode 100644 app/admin/(dashboard)/components/PostEditor.tsx delete mode 100644 app/admin/(dashboard)/layout.tsx delete mode 100644 app/admin/(dashboard)/page.tsx delete mode 100644 app/admin/(dashboard)/posts/[id]/edit/page.tsx delete mode 100644 app/admin/(dashboard)/posts/actions.ts delete mode 100644 app/admin/(dashboard)/posts/new/page.tsx delete mode 100644 app/admin/(dashboard)/posts/page.tsx delete mode 100644 app/admin/(dashboard)/subscriptions/ExportButton.tsx delete mode 100644 app/admin/(dashboard)/subscriptions/actions.ts delete mode 100644 app/admin/(dashboard)/subscriptions/page.tsx delete mode 100644 app/admin/(dashboard)/users/actions.ts delete mode 100644 app/admin/(dashboard)/users/page.tsx delete mode 100644 app/admin/layout.tsx delete mode 100644 app/admin/login/page.tsx delete mode 100644 app/api/comments/route.ts delete mode 100644 app/api/subscribe/route.ts delete mode 100644 components/CommentSection.tsx create mode 100644 lib/authors.ts delete mode 100644 proxy.ts delete mode 100644 scripts/migrate-posts.ts delete mode 100644 utils/supabase/client.ts delete mode 100644 utils/supabase/middleware.ts delete mode 100644 utils/supabase/server.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c9b0fe0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.next +.git +.env +.env.local +Dockerfile +.dockerignore +README.md +node-* diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..7e343ee --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,16 @@ +name: build-and-publish +on: + push: + branches: [master, main] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Log in to the Gitea registry + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thebennett.net -u "${{ secrets.REGISTRY_USER }}" --password-stdin + - name: Build + push + run: | + docker build -t git.thebennett.net/reformedwitness/confessions-of-grace:latest -t git.thebennett.net/reformedwitness/confessions-of-grace:${{ github.sha }} . + docker push git.thebennett.net/reformedwitness/confessions-of-grace:latest + docker push git.thebennett.net/reformedwitness/confessions-of-grace:${{ github.sha }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4cc28c6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0 +RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 +COPY --from=builder /app/public ./public +COPY --from=builder /app/data ./data +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +USER nextjs +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/app/admin/(dashboard)/authors/[name]/edit/page.tsx b/app/admin/(dashboard)/authors/[name]/edit/page.tsx deleted file mode 100644 index 949f016..0000000 --- a/app/admin/(dashboard)/authors/[name]/edit/page.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { createClient } from "@/utils/supabase/server"; -import { notFound } from "next/navigation"; -import { updateAuthor } from "../../actions"; - -interface PageProps { - params: Promise<{ name: string }>; -} - -export default async function EditAuthorPage({ params }: PageProps) { - const { name } = await params; - const decodedName = decodeURIComponent(name); - const supabase = await createClient(); - - const { data: author, error } = await supabase - .from("authors") - .select("name, bio, x_link, fb_link, insta_link, pfp_link") - .eq("name", decodedName) - .single(); - - if (error || !author) { - notFound(); - } - - return ( -
-

Edit Author

-
-
- -
- - -
-
- - -
- - {/* The save info checkbox logic can remain as it interacts with localStorage */} - {/*
- { - if (typeof window !== 'undefined') { - const saveInfo = e.target.checked; - localStorage.setItem('saveInfo', saveInfo.toString()); - if (saveInfo) { - localStorage.setItem('name', name); - localStorage.setItem('email', email); - } else { - localStorage.removeItem('name'); - localStorage.removeItem('email'); - } - } - }} - /> - -
*/} - - -
- -
-

Comments ({comments.length})

- - {isLoadingComments ? ( -

Loading comments...

- ) : comments.length === 0 ? ( -

No comments yet. Be the first to leave one!

- ) : ( -
- {comments.map((comment) => ( -
-
-
-

{comment.name}

-

- {new Date(comment.created_at).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - })} -

-
-
-

{comment.comment}

-
- ))} -
- )} -
-
- ); -}; - -export default CommentSection; \ No newline at end of file diff --git a/components/SubscribeForm.tsx b/components/SubscribeForm.tsx index f0c6749..0dedfa0 100644 --- a/components/SubscribeForm.tsx +++ b/components/SubscribeForm.tsx @@ -8,6 +8,9 @@ interface SubscribeFormProps { className?: string; } +// Email subscriptions require a backend which has been removed for the static +// build. This form is a no-op: it never calls any API and simply informs the +// user that subscriptions are unavailable. const SubscribeForm: React.FC = ({ placeholder = 'Your email', buttonLabel = 'Subscribe', @@ -15,40 +18,10 @@ const SubscribeForm: React.FC = ({ }) => { const [email, setEmail] = useState(''); const [message, setMessage] = useState(''); - const [loading, setLoading] = useState(false); - const handleSubscribe = async (e: React.FormEvent) => { + const handleSubscribe = (e: React.FormEvent) => { e.preventDefault(); - - if (!email) { - setMessage('Please enter your email.'); - return; - } - - setLoading(true); - setMessage(''); - - try { - const response = await fetch('/api/subscribe', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email }), - }); - - if (response.ok) { - setMessage('You have successfully subscribed!'); - setEmail(''); - } else { - const errorData = await response.json(); - setMessage(`Error: ${errorData.message}`); - } - } catch (error) { - setMessage('Something went wrong. Please try again.'); - } finally { - setLoading(false); - } + setMessage('Subscriptions are currently unavailable.'); }; return ( @@ -65,13 +38,12 @@ const SubscribeForm: React.FC = ({ {message &&

{message}

} ); }; -export default SubscribeForm; \ No newline at end of file +export default SubscribeForm; diff --git a/lib/authors.ts b/lib/authors.ts new file mode 100644 index 0000000..c5725b6 --- /dev/null +++ b/lib/authors.ts @@ -0,0 +1,57 @@ +import { getSortedPostsData, getPostsByAuthor } from "@/lib/posts"; +import { PostMetadata } from "@/types"; + +export interface AuthorSummary { + name: string; + slug: string; + postCount: number; + // No bio data is available in the static build; these remain optional. + bio?: string; + x_link?: string; + fb_link?: string; + insta_link?: string; + pfp_link?: string; +} + +export interface AuthorWithPosts extends AuthorSummary { + posts: PostMetadata[]; +} + +/** + * Derive the list of authors from the posts' frontmatter. + * The author `slug` is the author name itself, which is what + * `getPostsByAuthor` filters on and what the `[author]` route uses. + */ +export async function getAllAuthors(): Promise { + const posts = await getSortedPostsData(); + + const counts = new Map(); + for (const post of posts) { + if (!post.author) continue; + counts.set(post.author, (counts.get(post.author) || 0) + 1); + } + + return Array.from(counts.entries()) + .map(([name, postCount]) => ({ + name, + slug: name, + postCount, + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export async function getAuthor( + name: string, +): Promise { + const posts = await getPostsByAuthor(name); + if (posts.length === 0) { + return null; + } + + return { + name, + slug: name, + postCount: posts.length, + posts, + }; +} diff --git a/lib/posts.ts b/lib/posts.ts index 482b3b8..0d7868d 100644 --- a/lib/posts.ts +++ b/lib/posts.ts @@ -1,125 +1,94 @@ -import { createClient } from "@/utils/supabase/server"; +import fs from "fs"; +import path from "path"; +import matter from "gray-matter"; +import { remark } from "remark"; +import html from "remark-html"; import { PostData, PostMetadata } from "@/types"; -export async function getSortedPostsData(): Promise { - const supabase = await createClient(); +const postsDirectory = path.join(process.cwd(), "data/posts"); - const { data, error } = await supabase - .from("posts") - .select("id, title, date, excerpt, author, tags, cover_image") - .eq("published", true) - .order("date", { ascending: false }); +interface PostFrontmatter { + title: string; + date: string; + author: string; + excerpt: string; + tags?: string[]; + coverImage?: string; +} - if (error) { - console.error("Error fetching posts:", error); +function getPostIds(): string[] { + if (!fs.existsSync(postsDirectory)) { return []; } + return fs + .readdirSync(postsDirectory) + .filter((fileName) => fileName.endsWith(".md")) + .map((fileName) => fileName.replace(/\.md$/, "")); +} - return (data || []).map((post) => ({ - id: post.id, - title: post.title, - date: post.date, - excerpt: post.excerpt, - author: post.author, - tags: post.tags || [], - coverImage: post.cover_image || undefined, - })); +function readPostMetadata(id: string): PostMetadata { + const fullPath = path.join(postsDirectory, `${id}.md`); + const fileContents = fs.readFileSync(fullPath, "utf8"); + const { data } = matter(fileContents); + const frontmatter = data as PostFrontmatter; + + return { + id, + title: frontmatter.title, + date: frontmatter.date, + excerpt: frontmatter.excerpt, + author: frontmatter.author, + tags: frontmatter.tags || [], + coverImage: frontmatter.coverImage || undefined, + }; +} + +export async function getSortedPostsData(): Promise { + const posts = getPostIds().map((id) => readPostMetadata(id)); + + return posts.sort((a, b) => (a.date < b.date ? 1 : -1)); } export async function getAllPostIds(): Promise<{ params: { id: string } }[]> { - const supabase = await createClient(); - - const { data, error } = await supabase - .from("posts") - .select("id") - .eq("published", true); - - if (error) { - console.error("Error fetching post IDs:", error); - return []; - } - - return (data || []).map((post) => ({ - params: { id: post.id }, + return getPostIds().map((id) => ({ + params: { id }, })); } export async function getPostData(id: string): Promise { - const supabase = await createClient(); + const fullPath = path.join(postsDirectory, `${id}.md`); - const { data, error } = await supabase - .from("posts") - .select("id, title, date, excerpt, content_html, author, tags, cover_image") - .eq("id", id) - .single(); - - if (error || !data) { - console.error(`Error fetching post ${id}:`, error); + if (!fs.existsSync(fullPath)) { throw new Error(`Post not found: ${id}`); } + const fileContents = fs.readFileSync(fullPath, "utf8"); + const { data, content } = matter(fileContents); + const frontmatter = data as PostFrontmatter; + + const processedContent = await remark().use(html).process(content); + const contentHtml = processedContent.toString(); + return { - id: data.id, - title: data.title, - date: data.date, - excerpt: data.excerpt, - content: data.content_html, - author: data.author, - tags: data.tags || [], - coverImage: data.cover_image || undefined, + id, + title: frontmatter.title, + date: frontmatter.date, + excerpt: frontmatter.excerpt, + content: contentHtml, + author: frontmatter.author, + tags: frontmatter.tags || [], + coverImage: frontmatter.coverImage || undefined, }; } export async function getPostsByTag(tag: string): Promise { - const supabase = await createClient(); - - const { data, error } = await supabase - .from("posts") - .select("id, title, date, excerpt, author, tags, cover_image") - .eq("published", true) - .contains("tags", [tag]) - .order("date", { ascending: false }); - - if (error) { - console.error("Error fetching posts by tag:", error); - return []; - } - - return (data || []).map((post) => ({ - id: post.id, - title: post.title, - date: post.date, - excerpt: post.excerpt, - author: post.author, - tags: post.tags || [], - coverImage: post.cover_image || undefined, - })); + const posts = await getSortedPostsData(); + return posts.filter((post) => post.tags.includes(tag)); } export async function getPostsByAuthor( - author: string + author: string, ): Promise { - const supabase = await createClient(); - - const { data, error } = await supabase - .from("posts") - .select("id, title, date, excerpt, author, tags, cover_image") - .eq("published", true) - .eq("author", author) - .order("date", { ascending: false }); - - if (error) { - console.error("Error fetching posts by author:", error); - return []; - } - - return (data || []).map((post) => ({ - id: post.id, - title: post.title, - date: post.date, - excerpt: post.excerpt, - author: post.author, - tags: post.tags || [], - coverImage: post.cover_image || undefined, - })); + const posts = await getSortedPostsData(); + return posts.filter((post) => post.author === author); } diff --git a/next.config.ts b/next.config.ts index e9ffa30..68a6c64 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", }; export default nextConfig; diff --git a/proxy.ts b/proxy.ts deleted file mode 100644 index 2c70531..0000000 --- a/proxy.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { type NextRequest } from "next/server"; -import { updateSession } from "@/utils/supabase/middleware"; - -export async function proxy(request: NextRequest) { - return await updateSession(request); -} - -export const config = { - matcher: [ - /* - * Match all request paths except for the ones starting with: - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) - * - public folder assets - */ - "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", - ], -}; diff --git a/scripts/migrate-posts.ts b/scripts/migrate-posts.ts deleted file mode 100644 index 166642d..0000000 --- a/scripts/migrate-posts.ts +++ /dev/null @@ -1,78 +0,0 @@ -import dotenv from "dotenv"; -dotenv.config({ path: ".env.local" }); -import fs from "fs"; -import path from "path"; -import matter from "gray-matter"; -import { remark } from "remark"; -import html from "remark-html"; -import { createClient } from "@supabase/supabase-js"; - -const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; -const supabaseKey = - process.env.SUPABASE_SECRET_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY; - -if (!supabaseUrl || !supabaseKey) { - console.error( - "Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SECRET_KEY in .env.local" - ); - process.exit(1); -} - -const supabase = createClient(supabaseUrl, supabaseKey); - -const postsDirectory = path.join(process.cwd(), "data/posts"); - -async function migratePost(fileName: string) { - const id = fileName.replace(/\.md$/, ""); - const fullPath = path.join(postsDirectory, fileName); - const fileContents = fs.readFileSync(fullPath, "utf8"); - - const matterResult = matter(fileContents); - - // Render markdown to HTML - const processedContent = await remark() - .use(html, { sanitize: false }) - .process(matterResult.content); - const contentHtml = processedContent.toString(); - - const post = { - id, - title: matterResult.data.title || "", - date: new Date(matterResult.data.date).toISOString(), - excerpt: matterResult.data.excerpt || "", - content: matterResult.content, // raw markdown - content_html: contentHtml, - author: matterResult.data.author || "", - tags: matterResult.data.tags || [], - cover_image: matterResult.data.coverImage || null, - published: true, - }; - - const { error } = await supabase.from("posts").upsert(post, { - onConflict: "id", - }); - - if (error) { - console.error(`Failed to upsert post "${id}":`, error.message); - } else { - console.log(`Migrated: ${id}`); - } -} - -async function main() { - console.log("Starting post migration...\n"); - - const fileNames = fs - .readdirSync(postsDirectory) - .filter((f) => f.endsWith(".md")); - - console.log(`Found ${fileNames.length} markdown files.\n`); - - for (const fileName of fileNames) { - await migratePost(fileName); - } - - console.log("\nMigration complete!"); -} - -main().catch(console.error); diff --git a/utils/supabase/client.ts b/utils/supabase/client.ts deleted file mode 100644 index 2abf5b7..0000000 --- a/utils/supabase/client.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createBrowserClient } from "@supabase/ssr"; - -export function createClient() { - return createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY! - ); -} diff --git a/utils/supabase/middleware.ts b/utils/supabase/middleware.ts deleted file mode 100644 index b4d906c..0000000 --- a/utils/supabase/middleware.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { createServerClient } from "@supabase/ssr"; -import { NextResponse, type NextRequest } from "next/server"; - -export async function updateSession(request: NextRequest) { - let supabaseResponse = NextResponse.next({ - request, - }); - - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!, - { - cookies: { - getAll() { - return request.cookies.getAll(); - }, - setAll(cookiesToSet) { - cookiesToSet.forEach(({ name, value }) => - request.cookies.set(name, value) - ); - supabaseResponse = NextResponse.next({ - request, - }); - cookiesToSet.forEach(({ name, value, options }) => - supabaseResponse.cookies.set(name, value, options) - ); - }, - }, - } - ); - - // Refresh the session - const { - data: { user }, - } = await supabase.auth.getUser(); - - // Protect admin routes (except login) - const isAdminRoute = request.nextUrl.pathname.startsWith("/admin"); - const isLoginPage = request.nextUrl.pathname === "/admin/login"; - - if (isAdminRoute && !isLoginPage) { - if (!user) { - const url = request.nextUrl.clone(); - url.pathname = "/admin/login"; - return NextResponse.redirect(url); - } - - // Verify the user is in admin_users table - const { data: adminUser } = await supabase - .from("admin_users") - .select("role") - .eq("user_id", user.id) - .single(); - - if (!adminUser) { - const url = request.nextUrl.clone(); - url.pathname = "/admin/login"; - return NextResponse.redirect(url); - } - } - - // If logged-in admin visits login page, redirect to dashboard - if (isLoginPage && user) { - const { data: adminUser } = await supabase - .from("admin_users") - .select("role") - .eq("user_id", user.id) - .single(); - - if (adminUser) { - const url = request.nextUrl.clone(); - url.pathname = "/admin"; - return NextResponse.redirect(url); - } - } - - return supabaseResponse; -} diff --git a/utils/supabase/server.ts b/utils/supabase/server.ts deleted file mode 100644 index 6210a91..0000000 --- a/utils/supabase/server.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { createServerClient } from "@supabase/ssr"; -import { cookies } from "next/headers"; - -export async function createClient() { - const cookieStore = await cookies(); - - return createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!, - { - cookies: { - getAll() { - return cookieStore.getAll(); - }, - setAll(cookiesToSet) { - try { - cookiesToSet.forEach(({ name, value, options }) => - cookieStore.set(name, value, options) - ); - } catch { - // The `setAll` method was called from a Server Component. - // This can be ignored if you have middleware refreshing sessions. - } - }, - }, - } - ); -}