;
+ 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 });
+}
diff --git a/app/api/badges/route.ts b/app/api/badges/route.ts
new file mode 100644
index 0000000..7f11607
--- /dev/null
+++ b/app/api/badges/route.ts
@@ -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",
+ },
+ });
+}
diff --git a/app/globals.css b/app/globals.css
index cc93f9a..dcce869 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -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%);
+ }
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 6ab47a5..0ddae53 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -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({
+
{children}
diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx
index 6aff5ad..4ce67dd 100644
--- a/app/privacy/page.tsx
+++ b/app/privacy/page.tsx
@@ -82,6 +82,11 @@ export default function PrivacyPage() {
IP address, browser type, device information, and request logs
Error reports and operational diagnostics
+
+ Website and product analytics events collected via Matomo (self-hosted at
+ analytics.atakanozban.com) to understand traffic and improve the Service — see section
+ 10
+
3.4 Payment data
@@ -146,6 +151,11 @@ export default function PrivacyPage() {
)
+
+ Matomo (self-hosted analytics): 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.
+
These providers process data only as necessary to deliver their services and under
@@ -244,11 +254,26 @@ export default function PrivacyPage() {
6.
- 10. Cookies and local storage
+ 10. Cookies, analytics, and local storage
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.
+
+
+ We also use Matomo , a self-hosted analytics tool on{" "}
+ analytics.atakanozban.com, 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 *.songs2vid.com.
+
+
+ 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{" "}
+ {LEGAL_OPERATOR.email} .
11. Security
diff --git a/app/robots.ts b/app/robots.ts
index 332d58c..fd6a184 100644
--- a/app/robots.ts
+++ b/app/robots.ts
@@ -5,6 +5,7 @@ export default function robots(): MetadataRoute.Robots {
rules: {
userAgent: "*",
allow: "/",
+ disallow: ["/admin", "/api/admin"],
},
sitemap: "https://songs2vid.com/sitemap.xml",
};
diff --git a/components/AdminBadgePanel.tsx b/components/AdminBadgePanel.tsx
new file mode 100644
index 0000000..4cfbbda
--- /dev/null
+++ b/components/AdminBadgePanel.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(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 (
+
+ Admin unlock
+
+ Enter ADMIN_API_KEY to manage footer badges.
+ The key stays in this browser tab only (sessionStorage).
+
+
+ {error ? {error}
: null}
+
+ );
+ }
+
+ return (
+
+
+
+ Authenticated with admin API key. Changes apply to the live footer marquee.
+
+
+ Lock
+
+
+
+ {error ?
{error}
: null}
+ {success ?
{success}
: null}
+
+
+ Add badge
+
+ Paste a Product Hunt / LaunchBuff embed (HTML), or set image + link URLs.
+
+
+
+
+
+
+
Badges
+ loadBadges(apiKey)}
+ disabled={loading}
+ className="text-sm text-gray-400 hover:text-white"
+ >
+ {loading ? "Refreshing…" : "Refresh"}
+
+
+
+ {badges.length === 0 ? (
+ No badges yet.
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/components/BadgeEmbed.tsx b/components/BadgeEmbed.tsx
new file mode 100644
index 0000000..74231cc
--- /dev/null
+++ b/components/BadgeEmbed.tsx
@@ -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(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 (
+
+ );
+}
diff --git a/components/Footer.tsx b/components/Footer.tsx
index ad7bcf8..4c2d5d3 100644
--- a/components/Footer.tsx
+++ b/components/Footer.tsx
@@ -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 (
-
+
@@ -185,6 +198,8 @@ export function Footer() {
+
+
© {year} Songs2VID. All rights reserved.
diff --git a/components/FooterBadgeMarquee.tsx b/components/FooterBadgeMarquee.tsx
new file mode 100644
index 0000000..266f9d5
--- /dev/null
+++ b/components/FooterBadgeMarquee.tsx
@@ -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
(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 (
+
+
+ {loop.map((badge, index) => (
+
= badges.length ? true : undefined}
+ >
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/Matomo.tsx b/components/Matomo.tsx
new file mode 100644
index 0000000..6ad4167
--- /dev/null
+++ b/components/Matomo.tsx
@@ -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 (
+ <>
+
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+ >
+ );
+}
diff --git a/lib/admin-auth.ts b/lib/admin-auth.ts
new file mode 100644
index 0000000..fe3cd5c
--- /dev/null
+++ b/lib/admin-auth.ts
@@ -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;
+}
diff --git a/lib/footer-badges.ts b/lib/footer-badges.ts
new file mode 100644
index 0000000..ea85c48
--- /dev/null
+++ b/lib/footer-badges.ts
@@ -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 + nested/nearby 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(/ ]*\bhref\s*=\s*["']([^"']+)["'][^>]*>/i) ??
+ trimmed.match(/href\s*=\s*["']([^"']+)["']/i);
+ const srcMatch =
+ trimmed.match(/ ]*\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): 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;
+}
diff --git a/lib/legal/constants.ts b/lib/legal/constants.ts
index 39dfba1..3d53a08 100644
--- a/lib/legal/constants.ts
+++ b/lib/legal/constants.ts
@@ -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,
diff --git a/prisma/migrations/20260801190000_add_footer_badges/migration.sql b/prisma/migrations/20260801190000_add_footer_badges/migration.sql
new file mode 100644
index 0000000..8f1a8ed
--- /dev/null
+++ b/prisma/migrations/20260801190000_add_footer_badges/migration.sql
@@ -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',
+ ' ',
+ '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',
+ ' ',
+ 'https://launchbuff.com/badge-featured-dark.svg',
+ 'https://launchbuff.com',
+ true,
+ 1,
+ CURRENT_TIMESTAMP,
+ CURRENT_TIMESTAMP
+);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b55304b..5fba060 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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])
+}
diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts
index 4f07bbd..b323f37 100644
--- a/website/docusaurus.config.ts
+++ b/website/docusaurus.config.ts
@@ -24,6 +24,13 @@ const config: Config = {
locales: ['en'],
},
+ scripts: [
+ {
+ src: '/js/matomo.js',
+ async: true,
+ },
+ ],
+
presets: [
[
'classic',
diff --git a/website/static/js/matomo.js b/website/static/js/matomo.js
new file mode 100644
index 0000000..0a11118
--- /dev/null
+++ b/website/static/js/matomo.js
@@ -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);
+})();