From 26a1a19f3a1ca702ffc81e414b539ca63385c247 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 22 Feb 2026 10:47:53 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- README.md | 46 ++-- package.json | 5 +- src/app/admin/page.tsx | 440 ++++++++++++++++++++++++++++++++++ src/app/api/digest/route.ts | 42 ++++ src/app/api/messages/route.ts | 55 ++++- src/app/login/page.tsx | 134 +++++++++++ src/app/page.tsx | 3 + 7 files changed, 691 insertions(+), 34 deletions(-) create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/api/digest/route.ts create mode 100644 src/app/login/page.tsx diff --git a/README.md b/README.md index e215bc4..9eaa796 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,42 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Daily Digest Blog -## Getting Started +Next.js App Router blog with Supabase-backed posts and an authenticated admin panel. -First, run the development server: +## Run locally ```bash +npm install npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Dev server runs on `http://localhost:3002`. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## Environment variables -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +Set these in `.env.local`: -## Learn More +- `NEXT_PUBLIC_SUPABASE_URL` +- `NEXT_PUBLIC_SUPABASE_ANON_KEY` +- `SUPABASE_SERVICE_ROLE_KEY` (used by external scripts/tools if needed) +- `CRON_API_KEY` -To learn more about Next.js, take a look at the following resources: +## Public vs admin access -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +- Public blog (`/`) is open to everyone. +- Reading messages (`GET /api/messages`) is public. +- Admin UI (`/admin`) requires a signed-in Supabase user. +- If not signed in, `/admin` redirects to `/login`. +- Write APIs (`POST/DELETE /api/messages`) require either: + - a valid Supabase user bearer token, or + - `x-api-key: ` (for automation/cron). -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## Login flow -## Deploy on Vercel +1. Open `/login` +2. Sign in with a Supabase Auth email/password user +3. You are redirected to `/admin` -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Digest automation endpoint -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +- `POST /api/digest` requires `x-api-key: ` +- Used for cron-based digest publishing diff --git a/package.json b/package.json index 8d636d8..2c4bb37 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,11 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -p 3002", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "deploy": "npm run build && vercel --prod" }, "dependencies": { "@supabase/supabase-js": "^2.97.0", diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..901a4f2 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,440 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { createClient, type User } from "@supabase/supabase-js"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +interface Message { + id: string; + date: string; + content: string; + timestamp: number; + tags?: string[]; +} + +type Theme = "light" | "dark"; + +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! +); + +export default function AdminPage() { + const router = useRouter(); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [user, setUser] = useState(null); + const [editingPost, setEditingPost] = useState(null); + const [editContent, setEditContent] = useState(""); + const [editDate, setEditDate] = useState(""); + const [editTags, setEditTags] = useState(""); + const [showCreateModal, setShowCreateModal] = useState(false); + const [newContent, setNewContent] = useState(""); + const [newDate, setNewDate] = useState(""); + const [newTags, setNewTags] = useState("daily-digest"); + const [theme, setTheme] = useState(() => { + if (typeof window === "undefined") { + return "light"; + } + + const storedTheme = localStorage.getItem("theme"); + if (storedTheme === "light" || storedTheme === "dark") { + return storedTheme; + } + + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + }); + + useEffect(() => { + document.documentElement.classList.toggle("dark", theme === "dark"); + localStorage.setItem("theme", theme); + }, [theme]); + + useEffect(() => { + async function runAuthCheck() { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + router.replace("/login"); + return; + } + setUser(user); + } + + runAuthCheck(); + }, [router]); + + useEffect(() => { + if (user) { + fetchMessages(); + } + }, [user]); + + async function getAuthHeaders() { + const { data: { session } } = await supabase.auth.getSession(); + if (!session?.access_token) { + throw new Error("No active session"); + } + + return { + "Content-Type": "application/json", + Authorization: `Bearer ${session.access_token}`, + }; + } + + async function handleUnauthorized() { + await supabase.auth.signOut(); + router.replace("/login"); + } + + async function fetchMessages() { + try { + const res = await fetch("/api/messages"); + const data = await res.json(); + if (Array.isArray(data)) { + setMessages(data.sort((a, b) => b.timestamp - a.timestamp)); + } + } catch (error) { + console.error("Error fetching messages:", error); + } finally { + setLoading(false); + } + } + + async function handleDelete(id: string) { + if (!confirm("Are you sure you want to delete this post?")) return; + + try { + const headers = await getAuthHeaders(); + const res = await fetch("/api/messages", { + method: "DELETE", + headers, + body: JSON.stringify({ id }), + }); + + if (res.ok) { + setMessages(messages.filter((m) => m.id !== id)); + } else if (res.status === 401) { + await handleUnauthorized(); + } else { + alert("Failed to delete post"); + } + } catch (error) { + console.error("Error deleting:", error); + await handleUnauthorized(); + alert("Error deleting post"); + } + } + + function handleEdit(post: Message) { + setEditingPost(post); + setEditContent(post.content); + setEditDate(post.date); + setEditTags(post.tags?.join(", ") || ""); + } + + async function handleSaveEdit() { + if (!editingPost) return; + + try { + const headers = await getAuthHeaders(); + // Delete old post + const deleteRes = await fetch("/api/messages", { + method: "DELETE", + headers, + body: JSON.stringify({ id: editingPost.id }), + }); + + if (deleteRes.status === 401) { + await handleUnauthorized(); + return; + } + + if (!deleteRes.ok) { + alert("Failed to save changes"); + return; + } + + // Create new post with updated content + const res = await fetch("/api/messages", { + method: "POST", + headers, + body: JSON.stringify({ + content: editContent, + date: editDate, + tags: editTags.split(",").map((t) => t.trim()).filter(Boolean), + }), + }); + + if (res.ok) { + setEditingPost(null); + fetchMessages(); + } else if (res.status === 401) { + await handleUnauthorized(); + } else { + alert("Failed to save changes"); + } + } catch (error) { + console.error("Error saving:", error); + await handleUnauthorized(); + alert("Error saving changes"); + } + } + + async function handleCreate() { + try { + const headers = await getAuthHeaders(); + const res = await fetch("/api/messages", { + method: "POST", + headers, + body: JSON.stringify({ + content: newContent, + date: newDate, + tags: newTags.split(",").map((t) => t.trim()).filter(Boolean), + }), + }); + + if (res.ok) { + setShowCreateModal(false); + setNewContent(""); + setNewDate(""); + setNewTags("daily-digest"); + fetchMessages(); + } else if (res.status === 401) { + await handleUnauthorized(); + } else { + alert("Failed to create post"); + } + } catch (error) { + console.error("Error creating:", error); + await handleUnauthorized(); + alert("Error creating post"); + } + } + + async function handleLogout() { + await supabase.auth.signOut(); + router.replace("/login"); + } + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + if (!user) { + return ( +
+
Redirecting...
+
+ ); + } + + return ( +
+
+
+

Admin Dashboard

+
+ + + + View Site + + +
+
+
+ +
+
+
+

+ All Posts ({messages.length}) +

+
+ +
+ {messages.map((post) => ( +
+
+
+
+ + {new Date(post.timestamp).toLocaleDateString()} + + {post.tags?.map((tag) => ( + + {tag} + + ))} +
+
+ {post.content.substring(0, 200)}... +
+
+
+ + +
+
+
+ ))} +
+
+
+ + {/* Edit Modal */} + {editingPost && ( +
+
+
+

Edit Post

+
+
+
+ + setEditDate(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100" + /> +
+
+ + setEditTags(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100" + /> +
+
+ +