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.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Atakan Doğan Özban
2026-08-01 19:58:24 +02:00
co-authored by Cursor
parent 42ad9c38e3
commit 7d22f722f6
21 changed files with 1027 additions and 20 deletions
+5
View File
@@ -13,6 +13,10 @@ UPLOAD_DIR="./uploads"
# S2VID_EDITION="selfhosted"
# Admin API key for approving/rejecting quota extension requests (Bearer token)
# ADMIN_API_KEY="your-secret-admin-key"
# Admin UI: /admin (footer badges) — unlock with the same Bearer key
# Admin reset: POST /api/admin/reset-credits Authorization: Bearer $ADMIN_API_KEY
# Admin badges: GET/POST /api/admin/badges, PATCH/DELETE /api/admin/badges/:id
# Public badges: GET /api/badges
# Pro users generate API keys in Dashboard → Settings (stored hashed; shown once)
# Optional: override bundled ffmpeg-static binary (leave unset to use npm package)
# FFMPEG_PATH="C:/path/to/ffmpeg.exe"
@@ -44,3 +48,4 @@ NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/songs2vid"
# Optional: Managed Payments (MoR) enable in Dashboard + set managed_payments on Checkout if you use it
# Dev helpers: POST /api/dev/grant-credits { "action": "set-pro" | "free-top-up" | "grant-credits" }
# Admin reset: POST /api/admin/reset-credits Authorization: Bearer $ADMIN_API_KEY
# See also /admin for footer badge management
+26
View File
@@ -0,0 +1,26 @@
import { AdminBadgePanel } from "@/components/AdminBadgePanel";
export const metadata = {
title: "Admin · Footer badges · Songs2VID",
robots: { index: false, follow: false },
};
export default function AdminBadgesPage() {
return (
<main className="min-h-screen bg-surface-dark text-gray-100">
<div className="mx-auto max-w-3xl px-6 py-10">
<header className="mb-8">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Admin
</p>
<h1 className="mt-1 text-2xl font-bold text-white">Footer badges</h1>
<p className="mt-2 text-sm text-gray-400">
Manage the infinite badge marquee on the public site footer. Only active badges are
shown.
</p>
</header>
<AdminBadgePanel />
</div>
</main>
);
}
+95
View File
@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { prisma } from "@/lib/db";
import { normalizeBadgeFields, validateBadgeFields } from "@/lib/footer-badges";
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(req: NextRequest, ctx: Ctx) {
const denied = requireAdmin(req);
if (denied) return denied;
const { id } = await ctx.params;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const existing = await prisma.footerBadge.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "Badge not found" }, { status: 404 });
}
const input = (body ?? {}) as Record<string, unknown>;
const patch: {
name?: string;
embedHtml?: string | null;
imageUrl?: string | null;
linkUrl?: string | null;
isActive?: boolean;
sortOrder?: number;
} = {};
if ("name" in input) patch.name = typeof input.name === "string" ? input.name : existing.name;
if ("embedHtml" in input) {
patch.embedHtml = typeof input.embedHtml === "string" ? input.embedHtml : null;
}
if ("imageUrl" in input) {
patch.imageUrl = typeof input.imageUrl === "string" ? input.imageUrl : null;
}
if ("linkUrl" in input) {
patch.linkUrl = typeof input.linkUrl === "string" ? input.linkUrl : null;
}
if ("isActive" in input && typeof input.isActive === "boolean") {
patch.isActive = input.isActive;
}
if ("sortOrder" in input && typeof input.sortOrder === "number") {
patch.sortOrder = input.sortOrder;
}
const fields = normalizeBadgeFields({
name: patch.name ?? existing.name,
embedHtml: "embedHtml" in patch ? patch.embedHtml : existing.embedHtml,
imageUrl: "imageUrl" in patch ? patch.imageUrl : existing.imageUrl,
linkUrl: "linkUrl" in patch ? patch.linkUrl : existing.linkUrl,
isActive: patch.isActive ?? existing.isActive,
sortOrder: patch.sortOrder ?? existing.sortOrder,
});
// Toggle-only updates should not re-validate empty content
const contentChanging =
"name" in input || "embedHtml" in input || "imageUrl" in input || "linkUrl" in input;
if (contentChanging) {
const error = validateBadgeFields(fields);
if (error) return NextResponse.json({ error }, { status: 400 });
}
const badge = await prisma.footerBadge.update({
where: { id },
data: contentChanging
? fields
: {
...(patch.isActive !== undefined ? { isActive: patch.isActive } : {}),
...(patch.sortOrder !== undefined ? { sortOrder: fields.sortOrder } : {}),
},
});
return NextResponse.json({ badge });
}
export async function DELETE(req: NextRequest, ctx: Ctx) {
const denied = requireAdmin(req);
if (denied) return denied;
const { id } = await ctx.params;
const existing = await prisma.footerBadge.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "Badge not found" }, { status: 404 });
}
await prisma.footerBadge.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { prisma } from "@/lib/db";
import { normalizeBadgeFields, validateBadgeFields } from "@/lib/footer-badges";
export async function GET(req: NextRequest) {
const denied = requireAdmin(req);
if (denied) return denied;
const badges = await prisma.footerBadge.findMany({
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json({ badges });
}
export async function POST(req: NextRequest) {
const denied = requireAdmin(req);
if (denied) return denied;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const input = (body ?? {}) as Record<string, unknown>;
const fields = normalizeBadgeFields({
name: typeof input.name === "string" ? input.name : "",
embedHtml: typeof input.embedHtml === "string" ? input.embedHtml : null,
imageUrl: typeof input.imageUrl === "string" ? input.imageUrl : null,
linkUrl: typeof input.linkUrl === "string" ? input.linkUrl : null,
isActive: typeof input.isActive === "boolean" ? input.isActive : true,
sortOrder: typeof input.sortOrder === "number" ? input.sortOrder : undefined,
});
const error = validateBadgeFields(fields);
if (error) return NextResponse.json({ error }, { status: 400 });
if (fields.sortOrder === 0) {
const max = await prisma.footerBadge.aggregate({ _max: { sortOrder: true } });
fields.sortOrder = (max._max.sortOrder ?? -1) + 1;
}
const badge = await prisma.footerBadge.create({ data: fields });
return NextResponse.json({ badge }, { status: 201 });
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
/** Public: active footer badges for the landing marquee. */
export async function GET() {
const badges = 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,
},
});
return NextResponse.json({ badges }, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
},
});
}
+71
View File
@@ -116,6 +116,68 @@
.mobile-sidebar-link-danger:hover::before {
@apply bg-red-400;
}
.badge-marquee {
@apply relative w-full overflow-hidden;
mask-image: linear-gradient(
to right,
transparent,
black 8%,
black 92%,
transparent
);
}
.badge-marquee-track {
@apply flex w-max items-center gap-10;
animation: badge-marquee-scroll var(--badge-marquee-duration, 28s) linear infinite;
}
.badge-marquee:hover .badge-marquee-track {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.badge-marquee-track {
animation: none;
flex-wrap: wrap;
justify-content: center;
width: 100%;
}
.badge-marquee-item[aria-hidden="true"] {
display: none;
}
}
.badge-marquee-item {
@apply flex shrink-0 items-center justify-center;
}
.badge-marquee-slot {
@apply flex h-14 max-h-14 items-center justify-center;
}
.badge-marquee-slot a,
.badge-marquee-link {
@apply m-0 inline-flex items-center p-0 leading-none no-underline;
}
.badge-marquee-slot img,
.badge-marquee-slot iframe,
.badge-marquee-slot svg,
.badge-marquee-slot object {
display: block !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
max-height: 3.5rem !important;
width: auto !important;
height: auto !important;
max-width: min(280px, 70vw) !important;
object-fit: contain;
vertical-align: middle;
}
}
@keyframes mockup-float {
@@ -246,3 +308,12 @@
transform: scale(0.6) rotate(90deg);
}
}
@keyframes badge-marquee-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
+2
View File
@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
import { JsonLd } from "@/components/JsonLd";
import { Matomo } from "@/components/Matomo";
const inter = Inter({ subsets: ["latin"] });
@@ -84,6 +85,7 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<body className={inter.className} suppressHydrationWarning>
<JsonLd />
<Matomo />
<Providers>{children}</Providers>
</body>
</html>
+28 -3
View File
@@ -82,6 +82,11 @@ export default function PrivacyPage() {
</li>
<li>IP address, browser type, device information, and request logs</li>
<li>Error reports and operational diagnostics</li>
<li>
Website and product analytics events collected via Matomo (self-hosted at
analytics.atakanozban.com) to understand traffic and improve the Service see section
10
</li>
</ul>
<h3>3.4 Payment data</h3>
@@ -146,6 +151,11 @@ export default function PrivacyPage() {
</a>
)
</li>
<li>
<strong>Matomo (self-hosted analytics):</strong> privacy-friendly analytics we operate at
analytics.atakanozban.com to measure visits and improve Songs2VID. Analytics data stays on
infrastructure we control; we do not sell it to advertising networks.
</li>
</ul>
<p>
These providers process data only as necessary to deliver their services and under
@@ -244,11 +254,26 @@ export default function PrivacyPage() {
6.
</p>
<h2>10. Cookies and local storage</h2>
<h2>10. Cookies, analytics, and local storage</h2>
<p>
We use essential cookies and similar technologies for authentication, session management,
and security. We do not use non-essential tracking cookies unless disclosed separately and
enabled with your consent where required.
and security.
</p>
<p>
We also use <strong>Matomo</strong>, a self-hosted analytics tool on{" "}
<code>analytics.atakanozban.com</code>, on our website (including the landing page and
dashboard) and documentation site. Matomo helps us understand how people use Songs2VID so we
can improve functionality, reliability, and content. Typical data includes pages viewed,
approximate location derived from IP, device/browser type, referring site, and interaction
events. We configure Matomo for our domains under <code>*.songs2vid.com</code>.
</p>
<p>
We collect this analytics data to improve the Service, not to sell personal profiles to
advertisers. The legal basis is our legitimate interest in operating and improving
Songs2VID (and consent where required by local law). You can block analytics with browser
settings, extensions, or Do Not Track / equivalent controls where supported. For rights
requests related to analytics data, contact{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>.
</p>
<h2>11. Security</h2>
+1
View File
@@ -5,6 +5,7 @@ export default function robots(): MetadataRoute.Robots {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/admin", "/api/admin"],
},
sitemap: "https://songs2vid.com/sitemap.xml",
};
+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>
</>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
export function requireAdmin(req: NextRequest): NextResponse | null {
const adminKey = process.env.ADMIN_API_KEY;
if (!adminKey) {
return NextResponse.json({ error: "Admin API not configured" }, { status: 503 });
}
const auth = req.headers.get("authorization");
if (auth !== `Bearer ${adminKey}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return null;
}
+67
View File
@@ -0,0 +1,67 @@
export type FooterBadgeInput = {
name?: string;
embedHtml?: string | null;
imageUrl?: string | null;
linkUrl?: string | null;
isActive?: boolean;
sortOrder?: number;
};
export type ParsedBadgeEmbed = {
imageUrl: string | null;
linkUrl: string | null;
};
/** Extract first <a href> + nested/nearby <img src> from pasted badge HTML. */
export function parseBadgeEmbedHtml(html: string): ParsedBadgeEmbed {
const trimmed = html.trim();
if (!trimmed) return { imageUrl: null, linkUrl: null };
const hrefMatch =
trimmed.match(/<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>/i) ??
trimmed.match(/href\s*=\s*["']([^"']+)["']/i);
const srcMatch =
trimmed.match(/<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/i) ??
trimmed.match(/src\s*=\s*["']([^"']+)["']/i);
return {
linkUrl: hrefMatch?.[1]?.trim() || null,
imageUrl: srcMatch?.[1]?.trim() || null,
};
}
export function normalizeBadgeFields(input: FooterBadgeInput) {
const name = (input.name ?? "").trim();
const embedHtml = input.embedHtml?.trim() || null;
let imageUrl = input.imageUrl?.trim() || null;
let linkUrl = input.linkUrl?.trim() || null;
if (embedHtml) {
const parsed = parseBadgeEmbedHtml(embedHtml);
if (!imageUrl && parsed.imageUrl) imageUrl = parsed.imageUrl;
if (!linkUrl && parsed.linkUrl) linkUrl = parsed.linkUrl;
}
return {
name,
embedHtml,
imageUrl,
linkUrl,
isActive: input.isActive ?? true,
sortOrder:
typeof input.sortOrder === "number" && Number.isFinite(input.sortOrder)
? Math.trunc(input.sortOrder)
: 0,
};
}
export function validateBadgeFields(fields: ReturnType<typeof normalizeBadgeFields>): string | null {
if (!fields.name) return "Name is required";
if (!fields.embedHtml && !fields.imageUrl) {
return "Provide embed HTML or an image URL";
}
if (fields.imageUrl && !fields.linkUrl && !fields.embedHtml) {
return "Link URL is required when using image URL only";
}
return null;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { SUPPORT_EMAIL, SALES_EMAIL, QUOTA_REQUEST_EMAIL } from "@/lib/plans";
import { BRAND_DOMAIN, BRAND_NAME } from "@/lib/branding";
export const LEGAL_LAST_UPDATED = "July 25, 2026";
export const LEGAL_LAST_UPDATED = "July 30, 2026";
export const LEGAL_OPERATOR = {
name: BRAND_NAME,
@@ -0,0 +1,43 @@
-- CreateTable
CREATE TABLE "FooterBadge" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"embedHtml" TEXT,
"imageUrl" TEXT,
"linkUrl" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FooterBadge_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "FooterBadge_isActive_sortOrder_idx" ON "FooterBadge"("isActive", "sortOrder");
-- Seed current Product Hunt + LaunchBuff badges
INSERT INTO "FooterBadge" ("id", "name", "embedHtml", "imageUrl", "linkUrl", "isActive", "sortOrder", "createdAt", "updatedAt")
VALUES
(
'seed_producthunt',
'Product Hunt',
'<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"><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>',
'https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1206681&theme=dark&t=1785422871345',
'https://www.producthunt.com/products/songs2vid?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-songs2vid',
true,
0,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
),
(
'seed_launchbuff',
'LaunchBuff',
'<a href="https://launchbuff.com" target="_blank" rel="noopener noreferrer" title="Featured on LaunchBuff"><img src="https://launchbuff.com/badge-featured-dark.svg" alt="Featured on LaunchBuff" width="256" height="80" /></a>',
'https://launchbuff.com/badge-featured-dark.svg',
'https://launchbuff.com',
true,
1,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);
+18
View File
@@ -189,3 +189,21 @@ model JobItem {
youtubeVideoId String?
error String?
}
/// Site footer badge / launch-directory embeds (managed via /admin)
model FooterBadge {
id String @id @default(cuid())
name String
/// Raw paste from Product Hunt / LaunchBuff / etc. (preferred source)
embedHtml String? @db.Text
/// Parsed or manually set image URL (used when embedHtml is empty)
imageUrl String?
/// Parsed or manually set destination URL
linkUrl String?
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive, sortOrder])
}
+7
View File
@@ -24,6 +24,13 @@ const config: Config = {
locales: ['en'],
},
scripts: [
{
src: '/js/matomo.js',
async: true,
},
],
presets: [
[
'classic',
+18
View File
@@ -0,0 +1,18 @@
/* Matomo analytics for docs.songs2vid.com */
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 = "https://analytics.atakanozban.com/";
_paq.push(["setTrackerUrl", u + "matomo.php"]);
_paq.push(["setSiteId", "3"]);
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);
})();