Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-22 10:47:53 -06:00
parent 5211532923
commit 26a1a19f3a
7 changed files with 691 additions and 34 deletions

View File

@ -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: <CRON_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: <CRON_API_KEY>`
- Used for cron-based digest publishing

View File

@ -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",

440
src/app/admin/page.tsx Normal file
View File

@ -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<Message[]>([]);
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [editingPost, setEditingPost] = useState<Message | null>(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<Theme>(() => {
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 (
<div className="min-h-screen bg-gray-50 dark:bg-slate-950 text-gray-900 dark:text-slate-100 flex items-center justify-center">
<div className="text-lg">Loading...</div>
</div>
);
}
if (!user) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-slate-950 text-gray-900 dark:text-slate-100 flex items-center justify-center">
<div className="text-lg">Redirecting...</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 text-gray-900 dark:bg-slate-950 dark:text-slate-100">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10 dark:bg-slate-900 dark:border-slate-800">
<div className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-slate-100">Admin Dashboard</h1>
<div className="flex gap-4">
<button
type="button"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800"
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
>
{theme === "dark" ? "Light mode" : "Dark mode"}
</button>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
+ New Post
</button>
<Link href="/" className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800">
View Site
</Link>
<button
onClick={handleLogout}
className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800"
>
Logout
</button>
</div>
</div>
</header>
<main className="max-w-6xl mx-auto px-4 py-8">
<div className="bg-white rounded-lg shadow border border-gray-200 dark:bg-slate-900 dark:border-slate-800">
<div className="px-6 py-4 border-b border-gray-200 dark:border-slate-800">
<h2 className="text-lg font-semibold">
All Posts ({messages.length})
</h2>
</div>
<div className="divide-y divide-gray-200 dark:divide-slate-800">
{messages.map((post) => (
<div key={post.id} className="p-6 hover:bg-gray-50 dark:hover:bg-slate-800/40">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm text-gray-500 dark:text-slate-400">
{new Date(post.timestamp).toLocaleDateString()}
</span>
{post.tags?.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded dark:bg-blue-900/40 dark:text-blue-200"
>
{tag}
</span>
))}
</div>
<div className="text-sm text-gray-600 dark:text-slate-300 line-clamp-2">
{post.content.substring(0, 200)}...
</div>
</div>
<div className="flex gap-2 ml-4">
<button
onClick={() => handleEdit(post)}
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800"
>
Edit
</button>
<button
onClick={() => handleDelete(post.id)}
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
</div>
</main>
{/* Edit Modal */}
{editingPost && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-auto border border-gray-200 dark:bg-slate-900 dark:border-slate-800">
<div className="p-6 border-b border-gray-200 dark:border-slate-800">
<h3 className="text-lg font-semibold">Edit Post</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Date</label>
<input
type="text"
value={editDate}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Tags (comma separated)</label>
<input
type="text"
value={editTags}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Content (Markdown)</label>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded h-64 font-mono text-sm bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Preview</label>
<div className="border border-gray-300 rounded p-4 prose max-w-none max-h-64 overflow-auto bg-white dark:bg-slate-950 dark:border-slate-700">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{editContent}
</ReactMarkdown>
</div>
</div>
</div>
<div className="p-6 border-t border-gray-200 dark:border-slate-800 flex justify-end gap-2">
<button
onClick={() => setEditingPost(null)}
className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800"
>
Cancel
</button>
<button
onClick={handleSaveEdit}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Save Changes
</button>
</div>
</div>
</div>
)}
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-auto border border-gray-200 dark:bg-slate-900 dark:border-slate-800">
<div className="p-6 border-b border-gray-200 dark:border-slate-800">
<h3 className="text-lg font-semibold">New Post</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Date</label>
<input
type="text"
value={newDate}
onChange={(e) => setNewDate(e.target.value)}
placeholder="2026-02-22"
className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 placeholder:text-gray-400 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Tags (comma separated)</label>
<input
type="text"
value={newTags}
onChange={(e) => setNewTags(e.target.value)}
placeholder="daily-digest, news"
className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 placeholder:text-gray-400 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Content (Markdown)</label>
<textarea
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
placeholder="# Post Title\n\nYour content here..."
className="w-full px-3 py-2 border border-gray-300 rounded h-64 font-mono text-sm bg-white text-gray-900 placeholder:text-gray-400 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
</div>
</div>
<div className="p-6 border-t border-gray-200 dark:border-slate-800 flex justify-end gap-2">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800"
>
Cancel
</button>
<button
onClick={handleCreate}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Create Post
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
const CRON_API_KEY = process.env.CRON_API_KEY;
export async function POST(request: Request) {
const apiKey = request.headers.get("x-api-key");
if (!CRON_API_KEY || apiKey !== CRON_API_KEY) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { content, date, tags } = await request.json();
if (!content || !date) {
return NextResponse.json({ error: "Content and date required" }, { status: 400 });
}
const newMessage = {
id: Date.now().toString(),
date,
content,
timestamp: Date.now(),
tags: tags || ["daily-digest"],
};
const { error } = await supabase
.from("blog_messages")
.insert(newMessage);
if (error) {
console.error("Error saving digest:", error);
return NextResponse.json({ error: "Failed to save" }, { status: 500 });
}
return NextResponse.json({ success: true, id: newMessage.id });
}

View File

@ -6,12 +6,33 @@ const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
// Helper to check auth
async function getUser() {
const { data: { user } } = await supabase.auth.getUser();
return user;
function getBearerToken(request: Request): string | null {
const authorization = request.headers.get("authorization");
if (!authorization?.startsWith("Bearer ")) {
return null;
}
const token = authorization.slice("Bearer ".length).trim();
return token.length > 0 ? token : null;
}
async function getUserFromRequest(request: Request) {
const token = getBearerToken(request);
if (!token) {
return null;
}
const { data, error } = await supabase.auth.getUser(token);
if (error) {
console.error("Error validating auth token:", error.message);
return null;
}
return data.user;
}
const CRON_API_KEY = process.env.CRON_API_KEY;
// GET is public (read-only)
export async function GET() {
const { data: messages, error } = await supabase
@ -27,12 +48,17 @@ export async function GET() {
return NextResponse.json(messages || []);
}
// POST requires auth
// POST requires auth OR API key for cron jobs
export async function POST(request: Request) {
const user = await getUser();
const apiKey = request.headers.get("x-api-key");
const hasCronAccess = !!CRON_API_KEY && apiKey === CRON_API_KEY;
if (!hasCronAccess) {
const user = await getUserFromRequest(request);
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
const { content, date, tags } = await request.json();
@ -60,12 +86,17 @@ export async function POST(request: Request) {
return NextResponse.json(newMessage);
}
// DELETE requires auth
// DELETE requires auth OR API key
export async function DELETE(request: Request) {
const user = await getUser();
const apiKey = request.headers.get("x-api-key");
const hasCronAccess = !!CRON_API_KEY && apiKey === CRON_API_KEY;
if (!hasCronAccess) {
const user = await getUserFromRequest(request);
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
const { id } = await request.json();

134
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,134 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { createClient } from "@supabase/supabase-js";
import { useRouter } from "next/navigation";
type Theme = "light" | "dark";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [theme, setTheme] = useState<Theme>(() => {
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 checkExistingSession() {
const { data: { user } } = await supabase.auth.getUser();
if (user) {
router.replace("/admin");
}
}
checkExistingSession();
}, [router]);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError("");
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
router.replace("/admin");
}
return (
<div className="min-h-screen bg-gray-50 text-gray-900 dark:bg-slate-950 dark:text-slate-100 flex items-center justify-center px-4">
<div className="w-full max-w-md bg-white border border-gray-200 rounded-xl shadow-sm p-6 space-y-6 dark:bg-slate-900 dark:border-slate-700">
<div>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-slate-100">Admin Login</h1>
<button
type="button"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="px-3 py-1.5 rounded-lg border border-gray-300 text-xs font-medium text-gray-700 hover:bg-gray-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
>
{theme === "dark" ? "Light mode" : "Dark mode"}
</button>
</div>
<p className="text-sm text-gray-600 mt-1 dark:text-slate-300">
Sign in to manage blog posts.
</p>
</div>
<form className="space-y-4" onSubmit={handleSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
autoComplete="email"
className="w-full border border-gray-300 rounded-lg px-3 py-2 bg-white text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
autoComplete="current-password"
className="w-full border border-gray-300 rounded-lg px-3 py-2 bg-white text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
</div>
{error ? (
<p className="text-sm text-red-600">{error}</p>
) : null}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed"
>
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
</div>
</div>
);
}

View File

@ -165,6 +165,9 @@ function BlogPageContent() {
<Link href="https://mission-control-rho-pink.vercel.app" className="hidden md:inline text-sm text-gray-600 hover:text-gray-900 dark:text-slate-300 dark:hover:text-slate-100">
Mission Control
</Link>
<Link href="/admin" className="text-sm text-gray-600 hover:text-gray-900 dark:text-slate-300 dark:hover:text-slate-100">
Admin
</Link>
<button
type="button"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}