Add dynamic footer badge marquee with admin management.
Store badges in Postgres, expose public/admin APIs, render an infinite footer slider, and ship /admin for paste-embed CRUD.
This commit is contained in:
@@ -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",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user