Store badges in Postgres, expose public/admin APIs, render an infinite footer slider, and ship /admin for paste-embed CRUD. Co-authored-by: Cursor <cursoragent@cursor.com>
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
|
|
|
const STORAGE_KEY = "s2vid_admin_api_key";
|
|
|
|
export type AdminBadge = {
|
|
id: string;
|
|
name: string;
|
|
embedHtml: string | null;
|
|
imageUrl: string | null;
|
|
linkUrl: string | null;
|
|
isActive: boolean;
|
|
sortOrder: number;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
function authHeaders(key: string): HeadersInit {
|
|
return {
|
|
Authorization: `Bearer ${key}`,
|
|
"Content-Type": "application/json",
|
|
};
|
|
}
|
|
|
|
export function AdminBadgePanel() {
|
|
const [apiKey, setApiKey] = useState("");
|
|
const [unlocked, setUnlocked] = useState(false);
|
|
const [badges, setBadges] = useState<AdminBadge[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState<string | null>(null);
|
|
|
|
const [name, setName] = useState("");
|
|
const [embedHtml, setEmbedHtml] = useState("");
|
|
const [imageUrl, setImageUrl] = useState("");
|
|
const [linkUrl, setLinkUrl] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const stored = sessionStorage.getItem(STORAGE_KEY);
|
|
if (stored) {
|
|
setApiKey(stored);
|
|
setUnlocked(true);
|
|
}
|
|
}, []);
|
|
|
|
const loadBadges = useCallback(async (key: string) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch("/api/admin/badges", {
|
|
headers: authHeaders(key),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || "Failed to load badges");
|
|
setBadges(data.badges);
|
|
setUnlocked(true);
|
|
sessionStorage.setItem(STORAGE_KEY, key);
|
|
} catch (err) {
|
|
setUnlocked(false);
|
|
setBadges([]);
|
|
setError(err instanceof Error ? err.message : "Failed to load badges");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (unlocked && apiKey) {
|
|
void loadBadges(apiKey);
|
|
}
|
|
}, [unlocked, apiKey, loadBadges]);
|
|
|
|
async function unlock(e: FormEvent) {
|
|
e.preventDefault();
|
|
const key = apiKey.trim();
|
|
if (!key) {
|
|
setError("Enter ADMIN_API_KEY");
|
|
return;
|
|
}
|
|
await loadBadges(key);
|
|
}
|
|
|
|
function lock() {
|
|
sessionStorage.removeItem(STORAGE_KEY);
|
|
setApiKey("");
|
|
setUnlocked(false);
|
|
setBadges([]);
|
|
setSuccess(null);
|
|
setError(null);
|
|
}
|
|
|
|
async function createBadge(e: FormEvent) {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
setError(null);
|
|
setSuccess(null);
|
|
try {
|
|
const res = await fetch("/api/admin/badges", {
|
|
method: "POST",
|
|
headers: authHeaders(apiKey),
|
|
body: JSON.stringify({
|
|
name: name.trim(),
|
|
embedHtml: embedHtml.trim() || null,
|
|
imageUrl: imageUrl.trim() || null,
|
|
linkUrl: linkUrl.trim() || null,
|
|
isActive: true,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || "Failed to create badge");
|
|
setName("");
|
|
setEmbedHtml("");
|
|
setImageUrl("");
|
|
setLinkUrl("");
|
|
setSuccess(`Added “${data.badge.name}”`);
|
|
await loadBadges(apiKey);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to create badge");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function toggleActive(badge: AdminBadge) {
|
|
setError(null);
|
|
setSuccess(null);
|
|
try {
|
|
const res = await fetch(`/api/admin/badges/${badge.id}`, {
|
|
method: "PATCH",
|
|
headers: authHeaders(apiKey),
|
|
body: JSON.stringify({ isActive: !badge.isActive }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || "Failed to update badge");
|
|
setSuccess(
|
|
data.badge.isActive
|
|
? `“${data.badge.name}” is now active`
|
|
: `“${data.badge.name}” is now inactive`,
|
|
);
|
|
await loadBadges(apiKey);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to update badge");
|
|
}
|
|
}
|
|
|
|
async function removeBadge(badge: AdminBadge) {
|
|
if (!confirm(`Delete badge “${badge.name}”?`)) return;
|
|
setError(null);
|
|
setSuccess(null);
|
|
try {
|
|
const res = await fetch(`/api/admin/badges/${badge.id}`, {
|
|
method: "DELETE",
|
|
headers: authHeaders(apiKey),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || "Failed to delete badge");
|
|
setSuccess(`Deleted “${badge.name}”`);
|
|
await loadBadges(apiKey);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to delete badge");
|
|
}
|
|
}
|
|
|
|
if (!unlocked) {
|
|
return (
|
|
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
|
<h2 className="mb-2 text-lg font-semibold text-white">Admin unlock</h2>
|
|
<p className="mb-4 text-sm text-gray-400">
|
|
Enter <code className="text-gray-300">ADMIN_API_KEY</code> to manage footer badges.
|
|
The key stays in this browser tab only (sessionStorage).
|
|
</p>
|
|
<form onSubmit={unlock} className="flex flex-col gap-3 sm:flex-row">
|
|
<input
|
|
type="password"
|
|
value={apiKey}
|
|
onChange={(e) => setApiKey(e.target.value)}
|
|
placeholder="ADMIN_API_KEY"
|
|
className="input-field flex-1"
|
|
autoComplete="off"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="rounded bg-accent px-4 py-2 text-sm font-medium text-black hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
{loading ? "Checking…" : "Unlock"}
|
|
</button>
|
|
</form>
|
|
{error ? <p className="mt-3 text-sm text-red-400">{error}</p> : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<p className="text-sm text-gray-400">
|
|
Authenticated with admin API key. Changes apply to the live footer marquee.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={lock}
|
|
className="shrink-0 text-sm text-gray-400 underline-offset-2 hover:text-white hover:underline"
|
|
>
|
|
Lock
|
|
</button>
|
|
</div>
|
|
|
|
{error ? <p className="text-sm text-red-400">{error}</p> : null}
|
|
{success ? <p className="text-sm text-emerald-400">{success}</p> : null}
|
|
|
|
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
|
<h2 className="mb-1 text-lg font-semibold text-white">Add badge</h2>
|
|
<p className="mb-4 text-sm text-gray-400">
|
|
Paste a Product Hunt / LaunchBuff embed (HTML), or set image + link URLs.
|
|
</p>
|
|
<form onSubmit={createBadge} className="space-y-4">
|
|
<div>
|
|
<label className="mb-1 block text-xs font-semibold uppercase tracking-wider text-gray-500">
|
|
Name
|
|
</label>
|
|
<input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="input-field"
|
|
placeholder="Product Hunt"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs font-semibold uppercase tracking-wider text-gray-500">
|
|
Embed HTML / script
|
|
</label>
|
|
<textarea
|
|
value={embedHtml}
|
|
onChange={(e) => setEmbedHtml(e.target.value)}
|
|
className="input-field min-h-[120px] font-mono text-xs"
|
|
placeholder='<a href="..."><img src="..." /></a>'
|
|
spellCheck={false}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<label className="mb-1 block text-xs font-semibold uppercase tracking-wider text-gray-500">
|
|
Image URL (optional)
|
|
</label>
|
|
<input
|
|
value={imageUrl}
|
|
onChange={(e) => setImageUrl(e.target.value)}
|
|
className="input-field"
|
|
placeholder="https://…"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs font-semibold uppercase tracking-wider text-gray-500">
|
|
Link URL (optional)
|
|
</label>
|
|
<input
|
|
value={linkUrl}
|
|
onChange={(e) => setLinkUrl(e.target.value)}
|
|
className="input-field"
|
|
placeholder="https://…"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={saving}
|
|
className="rounded bg-accent px-4 py-2 text-sm font-medium text-black hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
{saving ? "Saving…" : "Add badge"}
|
|
</button>
|
|
</form>
|
|
</section>
|
|
|
|
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-white">Badges</h2>
|
|
<button
|
|
type="button"
|
|
onClick={() => loadBadges(apiKey)}
|
|
disabled={loading}
|
|
className="text-sm text-gray-400 hover:text-white"
|
|
>
|
|
{loading ? "Refreshing…" : "Refresh"}
|
|
</button>
|
|
</div>
|
|
|
|
{badges.length === 0 ? (
|
|
<p className="text-sm text-gray-500">No badges yet.</p>
|
|
) : (
|
|
<ul className="divide-y divide-gray-800">
|
|
{badges.map((badge) => (
|
|
<li
|
|
key={badge.id}
|
|
className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between"
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="font-medium text-white">{badge.name}</span>
|
|
<span
|
|
className={`rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
|
|
badge.isActive
|
|
? "bg-emerald-500/15 text-emerald-400"
|
|
: "bg-gray-700 text-gray-400"
|
|
}`}
|
|
>
|
|
{badge.isActive ? "Active" : "Inactive"}
|
|
</span>
|
|
<span className="text-xs text-gray-600">order {badge.sortOrder}</span>
|
|
</div>
|
|
<p className="mt-1 truncate text-xs text-gray-500">
|
|
{badge.linkUrl || badge.imageUrl || (badge.embedHtml ? "embed HTML" : "—")}
|
|
</p>
|
|
{badge.imageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={badge.imageUrl}
|
|
alt=""
|
|
className="mt-2 h-10 w-auto max-w-[200px] object-contain opacity-90"
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<div className="flex shrink-0 gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleActive(badge)}
|
|
className="rounded border border-gray-600 px-3 py-1.5 text-xs text-gray-200 hover:border-gray-400 hover:text-white"
|
|
>
|
|
{badge.isActive ? "Deactivate" : "Activate"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeBadge(badge)}
|
|
className="rounded border border-red-900/60 px-3 py-1.5 text-xs text-red-400 hover:border-red-500 hover:text-red-300"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|