Add dynamic footer badge marquee with admin management.

Store badges in Postgres, expose public/admin APIs, render an infinite footer slider, and ship /admin for paste-embed CRUD.
This commit is contained in:
Atakan Doğan Özban
2026-08-01 19:58:24 +02:00
parent 89c49bcd33
commit 6bf2bffc55
21 changed files with 1027 additions and 20 deletions
+349
View File
@@ -0,0 +1,349 @@
"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>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useRef } from "react";
type PublicBadge = {
id: string;
name: string;
embedHtml: string | null;
imageUrl: string | null;
linkUrl: string | null;
};
/**
* Renders a badge embed safely inside the marquee slot.
* Prefer structured image+link; fall back to HTML (re-executing scripts when present).
*/
export function BadgeEmbed({ badge }: { badge: PublicBadge }) {
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
host.replaceChildren();
if (badge.imageUrl && badge.linkUrl) {
const a = document.createElement("a");
a.href = badge.linkUrl;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.title = badge.name;
a.className = "badge-marquee-link";
const img = document.createElement("img");
img.src = badge.imageUrl;
img.alt = badge.name;
img.loading = "lazy";
img.decoding = "async";
a.appendChild(img);
host.appendChild(a);
return;
}
if (!badge.embedHtml) return;
const template = document.createElement("template");
template.innerHTML = badge.embedHtml.trim();
const scripts: HTMLScriptElement[] = [];
template.content.querySelectorAll("script").forEach((node) => {
scripts.push(node.cloneNode(true) as HTMLScriptElement);
node.remove();
});
host.appendChild(template.content.cloneNode(true));
for (const oldScript of scripts) {
const script = document.createElement("script");
for (const attr of oldScript.attributes) {
script.setAttribute(attr.name, attr.value);
}
if (oldScript.textContent) script.textContent = oldScript.textContent;
host.appendChild(script);
}
}, [badge.embedHtml, badge.imageUrl, badge.linkUrl, badge.name]);
return (
<div
ref={hostRef}
className="badge-marquee-slot"
data-badge-id={badge.id}
data-badge-name={badge.name}
/>
);
}
+31 -16
View File
@@ -1,6 +1,8 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { FooterBadgeMarquee } from "@/components/FooterBadgeMarquee";
import { Logo } from "@/components/Logo";
import { prisma } from "@/lib/db";
import {
DOCKER_HUB_URL,
DOCS_URL,
@@ -92,13 +94,38 @@ const STATUS_URL = "https://status.atakanozban.com/status/2";
const DOCS_INTRO_URL = `${DOCS_URL.replace(/\/$/, "")}/docs/intro`;
const DOCS_API_URL = `${DOCS_URL.replace(/\/$/, "")}/docs/api/overview`;
export function Footer() {
export async function Footer() {
const year = new Date().getFullYear();
let initialBadges: {
id: string;
name: string;
embedHtml: string | null;
imageUrl: string | null;
linkUrl: string | null;
sortOrder: number;
}[] = [];
try {
initialBadges = await prisma.footerBadge.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: {
id: true,
name: true,
embedHtml: true,
imageUrl: true,
linkUrl: true,
sortOrder: true,
},
});
} catch {
// Table may not exist yet during migrate; marquee client will retry via API.
}
return (
<footer className="mt-20 w-full border-t border-gray-800 bg-black/40 pb-8 pt-16 text-sm text-gray-400">
<div className="mx-auto max-w-6xl px-6">
<div className="grid grid-cols-1 gap-8 pb-12 md:grid-cols-12">
<div className="grid grid-cols-1 gap-8 pb-8 md:grid-cols-12">
<div className="flex flex-col gap-4 md:col-span-5">
<Logo size="sm" beta />
<p className="max-w-sm text-gray-500">
@@ -125,20 +152,6 @@ export function Footer() {
<DockerIcon className="h-5 w-5" />
</a>
</div>
<a
href="https://www.producthunt.com/products/songs2vid?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-songs2vid"
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-block"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt="Songs2VID - Bulk render audio & artworks into videos in seconds | Product Hunt"
width={250}
height={54}
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1206681&theme=dark&t=1785422871345"
/>
</a>
</div>
<div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:col-span-7">
@@ -185,6 +198,8 @@ export function Footer() {
</div>
</div>
<FooterBadgeMarquee initialBadges={initialBadges} />
<div className="flex flex-col items-center gap-3 border-t border-gray-900 pt-8 text-xs text-gray-500 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 sm:justify-start">
<span>© {year} Songs2VID. All rights reserved.</span>
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useEffect, useState } from "react";
import { BadgeEmbed } from "@/components/BadgeEmbed";
export type PublicFooterBadge = {
id: string;
name: string;
embedHtml: string | null;
imageUrl: string | null;
linkUrl: string | null;
sortOrder: number;
};
type Props = {
initialBadges?: PublicFooterBadge[];
};
export function FooterBadgeMarquee({ initialBadges = [] }: Props) {
const [badges, setBadges] = useState<PublicFooterBadge[]>(initialBadges);
useEffect(() => {
let cancelled = false;
fetch("/api/badges")
.then(async (res) => {
if (!res.ok) return;
const data = await res.json();
if (!cancelled && Array.isArray(data.badges)) {
setBadges(data.badges);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
if (badges.length === 0) return null;
// Duplicate track for seamless infinite scroll
const loop = [...badges, ...badges];
const durationSec = Math.max(18, badges.length * 10);
return (
<div className="badge-marquee mb-12" aria-label="Featured on">
<div
className="badge-marquee-track"
style={{ ["--badge-marquee-duration" as string]: `${durationSec}s` }}
>
{loop.map((badge, index) => (
<div
key={`${badge.id}-${index}`}
className="badge-marquee-item"
aria-hidden={index >= badges.length ? true : undefined}
>
<BadgeEmbed badge={badge} />
</div>
))}
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import Script from "next/script";
const MATOMO_URL = "https://analytics.atakanozban.com/";
const MATOMO_SITE_ID = "3";
/**
* First-party analytics (Matomo) for songs2vid.com + docs subdomain.
* Loaded once from the root layout so landing, dashboard, and legal pages share it.
*/
export function Matomo() {
return (
<>
<Script id="matomo-analytics" strategy="afterInteractive">{`
var _paq = window._paq = window._paq || [];
_paq.push(["setDocumentTitle", document.domain + "/" + document.title]);
_paq.push(["setCookieDomain", "*.songs2vid.com"]);
_paq.push(["setDomains", ["*.songs2vid.com"]]);
_paq.push(["trackPageView"]);
_paq.push(["enableLinkTracking"]);
(function () {
var u = ${JSON.stringify(MATOMO_URL)};
_paq.push(["setTrackerUrl", u + "matomo.php"]);
_paq.push(["setSiteId", ${JSON.stringify(MATOMO_SITE_ID)}]);
var d = document, g = d.createElement("script"), s = d.getElementsByTagName("script")[0];
g.async = true;
g.src = u + "matomo.js";
s.parentNode.insertBefore(g, s);
})();
`}</Script>
<noscript>
<p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
referrerPolicy="no-referrer-when-downgrade"
src={`${MATOMO_URL}matomo.php?idsite=${MATOMO_SITE_ID}&rec=1`}
style={{ border: 0 }}
alt=""
/>
</p>
</noscript>
</>
);
}