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
|
||||
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
|
||||
|
||||
@ -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
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!
|
||||
);
|
||||
|
||||
// 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
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">
|
||||
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")}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user