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:
co-authored by
Cursor
parent
42ad9c38e3
commit
7d22f722f6
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -5,6 +5,7 @@ export default function robots(): MetadataRoute.Robots {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/admin", "/api/admin"],
|
||||
},
|
||||
sitemap: "https://songs2vid.com/sitemap.xml",
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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
|
||||
);
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -24,6 +24,13 @@ const config: Config = {
|
||||
locales: ['en'],
|
||||
},
|
||||
|
||||
scripts: [
|
||||
{
|
||||
src: '/js/matomo.js',
|
||||
async: true,
|
||||
},
|
||||
],
|
||||
|
||||
presets: [
|
||||
[
|
||||
'classic',
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user