Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
5211532923
commit
26a1a19f3a
46
README.md
46
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
|
```bash
|
||||||
|
npm install
|
||||||
npm run dev
|
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.
|
- Public blog (`/`) is open to everyone.
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
- 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
|
||||||
|
|||||||
@ -3,10 +3,11 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev -p 3002",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"deploy": "npm run build && vercel --prod"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.97.0",
|
"@supabase/supabase-js": "^2.97.0",
|
||||||
|
|||||||
440
src/app/admin/page.tsx
Normal file
440
src/app/admin/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/app/api/digest/route.ts
Normal file
42
src/app/api/digest/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
@ -6,12 +6,33 @@ const supabase = createClient(
|
|||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper to check auth
|
function getBearerToken(request: Request): string | null {
|
||||||
async function getUser() {
|
const authorization = request.headers.get("authorization");
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
if (!authorization?.startsWith("Bearer ")) {
|
||||||
return user;
|
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)
|
// GET is public (read-only)
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const { data: messages, error } = await supabase
|
const { data: messages, error } = await supabase
|
||||||
@ -27,11 +48,16 @@ export async function GET() {
|
|||||||
return NextResponse.json(messages || []);
|
return NextResponse.json(messages || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST requires auth
|
// POST requires auth OR API key for cron jobs
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const user = await getUser();
|
const apiKey = request.headers.get("x-api-key");
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
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();
|
const { content, date, tags } = await request.json();
|
||||||
@ -60,11 +86,16 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json(newMessage);
|
return NextResponse.json(newMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE requires auth
|
// DELETE requires auth OR API key
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
const user = await getUser();
|
const apiKey = request.headers.get("x-api-key");
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
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();
|
const { id } = await request.json();
|
||||||
|
|||||||
134
src/app/login/page.tsx
Normal file
134
src/app/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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">
|
<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
|
Mission Control
|
||||||
</Link>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user